feat: custom elements rework (#8457)

This is an overhaul of custom elements in Svelte. Instead of compiling to a custom element class, the Svelte component class is mostly preserved as-is. Instead a wrapper is introduced which wraps a Svelte component constructor and returns a HTML element constructor. This has a couple of advantages:

- component can be used both as a custom element as well as a regular component. This allows creating one wrapper custom element and using regular Svelte components inside. Fixes #3594, fixes #3128, fixes #4274, fixes #5486, fixes #3422, fixes #2969, helps with https://github.com/sveltejs/kit/issues/4502
- all components are compiled with injected styles (inlined through Javascript), fixes #4274
- the wrapper instantiates the component in `connectedCallback` and disconnects it in `disconnectedCallback` (but only after one tick, because this could be a element move). Mount/destroy works as expected inside, fixes #5989, fixes #8191
- the wrapper forwards `addEventListener` calls to `component.$on`, which allows to listen to custom events, fixes #3119, closes #4142 
- some things are hard to auto-configure, like attribute hyphen preferences or whether or not setting a property should reflect back to the attribute. This is why `<svelte:options customElement={..}>` can also take an object to modify such aspects. This option allows to specify whether setting a prop should be reflected back to the attribute (default `false`), what to use when converting the property to the attribute value and vice versa (through `type`, default `String`, or when `export let prop = false` then `Boolean`), and what the corresponding attribute for the property is (`attribute`, default lowercased prop name). These options are heavily inspired by lit: https://lit.dev/docs/components/properties. Closes #7638, fixes #5705
- adds a `shadowdom` option to control whether or not encapsulate the custom element. Closes #4330, closes #1748 

Breaking changes:
- Wrapped Svelte component now stays as a regular Svelte component (invokeing it like before with `new Component({ target: ..})` won't create a custom element). Its custom element constructor is now a static property named `element` on the class (`Component.element`) and should be regularly invoked through setting it in the html.
- The timing of mount/destroy/update is different. Mount/destroy/updating a prop all happen after a tick, so `shadowRoot.innerHTML` won't immediately reflect the change (Lit does this too). If you rely on it, you need to await a promise
pull/8566/head
Simon H 1 year ago committed by GitHub
parent 0677d89fff
commit d083f8a3f2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -10,5 +10,8 @@ module.exports = {
'estree'
],
'svelte3/compiler': require('./compiler')
},
rules: {
'@typescript-eslint/no-non-null-assertion': 'off'
}
};

@ -8,6 +8,7 @@
* **breaking** Stricter types for `createEventDispatcher` (see PR for migration instructions) ([#7224](https://github.com/sveltejs/svelte/pull/7224))
* **breaking** Stricter types for `Action` and `ActionReturn` (see PR for migration instructions) ([#7224](https://github.com/sveltejs/svelte/pull/7224))
* **breaking** Stricter types for `onMount` - now throws a type error when returning a function asynchronously to catch potential mistakes around callback functions (see PR for migration instructions) ([#8136](https://github.com/sveltejs/svelte/pull/8136))
* **breaking** Overhaul and drastically improve creating custom elements with Svelte (see PR for list of changes and migration instructions) ([#8457](https://github.com/sveltejs/svelte/pull/8457))
* **breaking** Deprecate `SvelteComponentTyped`, use `SvelteComponent` instead ([#8512](https://github.com/sveltejs/svelte/pull/8512))
* Add `a11y no-noninteractive-element-interactions` rule ([#8391](https://github.com/sveltejs/svelte/pull/8391))
* Add `a11y-no-static-element-interactions`rule ([#8251](https://github.com/sveltejs/svelte/pull/8251))

@ -1597,7 +1597,17 @@ export interface SvelteHTMLElements {
'svelte:document': HTMLAttributes<Document>;
'svelte:body': HTMLAttributes<HTMLElement>;
'svelte:fragment': { slot?: string };
'svelte:options': { [name: string]: any };
'svelte:options': {
customElement?: string | undefined | {
tag: string;
shadow?: 'open' | 'none' | undefined;
props?: Record<string, { attribute?: string; reflect?: boolean; type?: 'String' | 'Boolean' | 'Number' | 'Array' | 'Object' }> | undefined;
};
immutable?: boolean | undefined;
accessors?: boolean | undefined;
namespace?: string | undefined;
[name: string]: any
};
'svelte:head': { [name: string]: any };
[name: string]: { [name: string]: any };

@ -15,9 +15,12 @@ const is_publish = !!process.env.PUBLISH;
const ts_plugin = is_publish
? typescript({
typescript: require('typescript'),
paths: {
'svelte/*': ['./src/runtime/*']
}
})
: sucrase({
transforms: ['typescript'],
transforms: ['typescript']
});
fs.writeFileSync(

@ -1825,10 +1825,10 @@ The `<svelte:options>` element provides a place to specify per-component compile
* `accessors={true}` — adds getters and setters for the component's props
* `accessors={false}` — the default
* `namespace="..."` — the namespace where this component will be used, most commonly "svg"; use the "foreign" namespace to opt out of case-insensitive attribute names and HTML-specific warnings
* `tag="..."` — the name to use when compiling this component as a custom element
* `customElement="..."` — the name to use when compiling this component as a custom element
```sv
<svelte:options tag="my-custom-element"/>
<svelte:options customElement="my-custom-element"/>
```
### `<svelte:fragment>`

@ -1118,7 +1118,7 @@ app.count += 1;
Svelte components can also be compiled to custom elements (aka web components) using the `customElement: true` compiler option. You should specify a tag name for the component using the `<svelte:options>` [element](/docs#template-syntax-svelte-options).
```sv
<svelte:options tag="my-element" />
<svelte:options customElement="my-element" />
<script>
export let name = 'world';
@ -1130,12 +1130,12 @@ Svelte components can also be compiled to custom elements (aka web components) u
---
Alternatively, use `tag={null}` to indicate that the consumer of the custom element should name it.
You can leave out the tag name for any of your inner components which you don't want to expose and use them like regular Svelte components. Consumers of the component can still name it afterwards if needed, using the static `element` property which contains the custom element constructor and which is available when the `customElement` compiler option is `true`.
```js
import MyElement from './MyElement.svelte';
customElements.define('my-element', MyElement);
customElements.define('my-element', MyElement.element);
```
---
@ -1166,15 +1166,42 @@ console.log(el.name);
el.name = 'everybody';
```
---
When constructing a custom element, you can tailor several aspects by defining `customElement` as an object within `<svelte:options>`. This object comprises a mandatory `tag` property for the custom element's name, an optional `shadow` property that can be set to `"none"` to forgo shadow root creation, and a `props` option, which offers the following settings:
- `attribute: string`: To update a custom element's prop, you have two alternatives: either set the property on the custom element's reference as illustrated above or use an HTML attribute. For the latter, the default attribute name is the lowercase property name. Modify this by assigning `attribute: "<desired name>"`.
- `reflect: boolean`: By default, updated prop values do not reflect back to the DOM. To enable this behavior, set `reflect: true`.
- `type: 'String' | 'Boolean' | 'Number' | 'Array' | 'Object'`: While converting an attribute value to a prop value and reflecting it back, the prop value is assumed to be a `String` by default. This may not always be accurate. For instance, for a number type, define it using `type: "Number"`
```svelte
<svelte:options
customElement={{
tag: "custom-element",
shadow: "none",
props: {
name: { reflect: true, type: "Number", attribute: "element-index" },
},
}}
/>
<script>
export let elementIndex;
</script>
...
```
Custom elements can be a useful way to package components for consumption in a non-Svelte app, as they will work with vanilla HTML and JavaScript as well as [most frameworks](https://custom-elements-everywhere.com/). There are, however, some important differences to be aware of:
* Styles are *encapsulated*, rather than merely *scoped*. This means that any non-component styles (such as you might have in a `global.css` file) will not apply to the custom element, including styles with the `:global(...)` modifier
* Styles are *encapsulated*, rather than merely *scoped* (unless you set `shadow: "none"`). This means that any non-component styles (such as you might have in a `global.css` file) will not apply to the custom element, including styles with the `:global(...)` modifier
* Instead of being extracted out as a separate .css file, styles are inlined into the component as a JavaScript string
* Custom elements are not generally suitable for server-side rendering, as the shadow DOM is invisible until JavaScript loads
* In Svelte, slotted content renders *lazily*. In the DOM, it renders *eagerly*. In other words, it will always be created even if the component's `<slot>` element is inside an `{#if ...}` block. Similarly, including a `<slot>` in an `{#each ...}` block will not cause the slotted content to be rendered multiple times
* The `let:` directive has no effect
* The `let:` directive has no effect, because custom elements do not have a way to pass data to the parent component that fills the slot
* Polyfills are required to support older browsers
When a custom element written with Svelte is created or updated, the shadow dom will reflect the value in the next tick, not immediately. This way updates can be batched, and DOM moves which temporarily (but synchronously) detach the element from the DOM don't lead to unmounting the inner component.
### Server-side component API

@ -25,6 +25,6 @@ The options that can be set here are:
* `accessors={true}` — adds getters and setters for the component's props
* `accessors={false}` — the default
* `namespace="..."` — the namespace where this component will be used, most commonly `"svg"`
* `tag="..."` — the name to use when compiling this component as a custom element
* `customElement="..."` — the name to use when compiling this component as a custom element
Consult the [API reference](/docs) for more information on these options.

@ -16,7 +16,7 @@ import Stylesheet from './css/Stylesheet';
import { test } from '../config';
import Fragment from './nodes/Fragment';
import internal_exports from './internal_exports';
import { Ast, CompileOptions, Var, Warning, CssResult } from '../interfaces';
import { Ast, CompileOptions, Var, Warning, CssResult, Attribute } from '../interfaces';
import error from '../utils/error';
import get_code_frame from '../utils/get_code_frame';
import flatten_reference from './utils/flatten_reference';
@ -26,7 +26,7 @@ import TemplateScope from './nodes/shared/TemplateScope';
import fuzzymatch from '../utils/fuzzymatch';
import get_object from './utils/get_object';
import Slot from './nodes/Slot';
import { Node, ImportDeclaration, ExportNamedDeclaration, Identifier, ExpressionStatement, AssignmentExpression, Literal, Property, RestElement, ExportDefaultDeclaration, ExportAllDeclaration, FunctionDeclaration, FunctionExpression } from 'estree';
import { Node, ImportDeclaration, ExportNamedDeclaration, Identifier, ExpressionStatement, AssignmentExpression, Literal, Property, RestElement, ExportDefaultDeclaration, ExportAllDeclaration, FunctionDeclaration, FunctionExpression, ObjectExpression } from 'estree';
import add_to_set from './utils/add_to_set';
import check_graph_for_cycles from './utils/check_graph_for_cycles';
import { print, b } from 'code-red';
@ -42,10 +42,14 @@ import Tag from './nodes/shared/Tag';
interface ComponentOptions {
namespace?: string;
tag?: string;
immutable?: boolean;
accessors?: boolean;
preserveWhitespace?: boolean;
customElement?: {
tag: string | null;
shadow?: 'open' | 'none';
props?: Record<string, { attribute?: string; reflect?: boolean; type?: 'String' | 'Boolean' | 'Number' | 'Array' | 'Object' }>;
};
}
const regex_leading_directory_separator = /^[/\\]/;
@ -167,16 +171,7 @@ export default class Component {
this.component_options.namespace;
if (compile_options.customElement) {
if (
this.component_options.tag === undefined &&
compile_options.tag === undefined
) {
const svelteOptions = ast.html.children.find(
child => child.name === 'svelte:options'
) || { start: 0, end: 0 };
this.warn(svelteOptions, compiler_warnings.custom_element_no_tag);
}
this.tag = this.component_options.tag || compile_options.tag;
this.tag = this.component_options.customElement?.tag || compile_options.tag || this.name.name;
} else {
this.tag = this.name.name;
}
@ -195,7 +190,7 @@ export default class Component {
this.pop_ignores();
this.elements.forEach(element => this.stylesheet.apply(element));
if (!compile_options.customElement) this.stylesheet.reify();
this.stylesheet.reify();
this.stylesheet.warn_on_unused_selectors(this);
}
@ -547,6 +542,9 @@ export default class Component {
extract_names(declarator.id).forEach(name => {
const variable = this.var_lookup.get(name);
variable.export_name = name;
if (declarator.init?.type === 'Literal' && typeof declarator.init.value === 'boolean') {
variable.is_boolean = true;
}
if (!module_script && variable.writable && !(variable.referenced || variable.referenced_from_script || variable.subscribable)) {
this.warn(declarator as any, compiler_warnings.unused_export_let(this.name.name, name));
}
@ -1560,23 +1558,99 @@ function process_component_options(component: Component, nodes) {
if (attribute.type === 'Attribute') {
const { name } = attribute;
function parse_tag(attribute: Attribute, tag: string) {
if (typeof tag !== 'string' && tag !== null) {
return component.error(attribute, compiler_errors.invalid_tag_attribute);
}
if (tag && !regex_valid_tag_name.test(tag)) {
return component.error(attribute, compiler_errors.invalid_tag_property);
}
if (tag && !component.compile_options.customElement) {
component.warn(attribute, compiler_warnings.missing_custom_element_compile_options);
}
component_options.customElement = component_options.customElement || {} as any;
component_options.customElement.tag = tag;
}
switch (name) {
case 'tag': {
const tag = get_value(attribute, compiler_errors.invalid_tag_attribute);
component.warn(attribute, compiler_warnings.tag_option_deprecated);
parse_tag(attribute, get_value(attribute, compiler_errors.invalid_tag_attribute));
break;
}
if (typeof tag !== 'string' && tag !== null) {
return component.error(attribute, compiler_errors.invalid_tag_attribute);
case 'customElement': {
component_options.customElement = component_options.customElement || {} as any;
const { value } = attribute;
if (value[0].type === 'MustacheTag' && value[0].expression?.value === null) {
component_options.customElement.tag = null;
break;
} else if (value[0].type === 'Text') {
parse_tag(attribute, get_value(attribute, compiler_errors.invalid_tag_attribute));
break;
} else if (value[0].expression.type !== 'ObjectExpression') {
return component.error(attribute, compiler_errors.invalid_customElement_attribute);
}
if (tag && !regex_valid_tag_name.test(tag)) {
return component.error(attribute, compiler_errors.invalid_tag_property);
const tag = value[0].expression.properties.find(
(prop: any) => prop.key.name === 'tag'
);
if (tag) {
parse_tag(tag, tag.value?.value);
} else {
return component.error(attribute, compiler_errors.invalid_customElement_attribute);
}
if (tag && !component.compile_options.customElement) {
component.warn(attribute, compiler_warnings.missing_custom_element_compile_options);
const props = value[0].expression.properties.find(
(prop: any) => prop.key.name === 'props'
);
if (props) {
const error = () => component.error(attribute, compiler_errors.invalid_props_attribute);
if (props.value?.type !== 'ObjectExpression') {
return error();
}
component_options.customElement.props = {};
for (const property of (props.value as ObjectExpression).properties) {
if (property.type !== 'Property' || property.computed || property.key.type !== 'Identifier' || property.value.type !== 'ObjectExpression') {
return error();
}
component_options.customElement.props[property.key.name] = {};
for (const prop of property.value.properties) {
if (prop.type !== 'Property' || prop.computed || prop.key.type !== 'Identifier' || prop.value.type !== 'Literal') {
return error();
}
if (['reflect', 'attribute', 'type'].indexOf(prop.key.name) === -1 ||
prop.key.name === 'type' && ['String', 'Number', 'Boolean', 'Array', 'Object'].indexOf(prop.value.value as string) === -1 ||
prop.key.name === 'reflect' && typeof prop.value.value !== 'boolean' ||
prop.key.name === 'attribute' && typeof prop.value.value !== 'string'
) {
return error();
}
component_options.customElement.props[property.key.name][prop.key.name] = prop.value.value;
}
}
}
const shadow = value[0].expression.properties.find(
(prop: any) => prop.key.name === 'shadow'
);
if (shadow) {
const shadowdom = shadow.value?.value;
if (shadowdom !== 'open' && shadowdom !== 'none') {
return component.error(shadow, compiler_errors.invalid_shadow_attribute);
}
component_options.customElement.shadow = shadowdom;
}
component_options.tag = tag;
break;
}
@ -1610,7 +1684,7 @@ function process_component_options(component: Component, nodes) {
}
default:
return component.error(attribute, compiler_errors.invalid_options_attribute_unknown);
return component.error(attribute, compiler_errors.invalid_options_attribute_unknown(name));
}
} else {
return component.error(attribute, compiler_errors.invalid_options_attribute);

@ -202,10 +202,24 @@ export default {
code: 'invalid-tag-property',
message: "tag name must be two or more words joined by the '-' character"
},
invalid_customElement_attribute: {
code: 'invalid-customElement-attribute',
message: "'customElement' must be a string literal defining a valid custom element name or an object of the form " +
"{ tag: string; shadow?: 'open' | 'none'; props?: { [key: string]: { attribute?: string; reflect?: boolean; type: .. } } }"
},
invalid_tag_attribute: {
code: 'invalid-tag-attribute',
message: "'tag' must be a string literal"
},
invalid_shadow_attribute: {
code: 'invalid-shadow-attribute',
message: "'shadow' must be either 'open' or 'none'"
},
invalid_props_attribute: {
code: 'invalid-props-attribute',
message: "'props' must be a statically analyzable object literal of the form " +
"'{ [key: string]: { attribute?: string; reflect?: boolean; type?: 'String' | 'Boolean' | 'Number' | 'Array' | 'Object' }'"
},
invalid_namespace_property: (namespace: string, suggestion?: string) => ({
code: 'invalid-namespace-property',
message: `Invalid namespace '${namespace}'` + (suggestion ? ` (did you mean '${suggestion}'?)` : '')
@ -218,10 +232,10 @@ export default {
code: `invalid-${name}-value`,
message: `${name} attribute must be true or false`
}),
invalid_options_attribute_unknown: {
invalid_options_attribute_unknown: (name: string) => ({
code: 'invalid-options-attribute',
message: '<svelte:options> unknown attribute'
},
message: `<svelte:options> unknown attribute '${name}'`
}),
invalid_options_attribute: {
code: 'invalid-options-attribute',
message: "<svelte:options> can only have static 'tag', 'namespace', 'accessors', 'immutable' and 'preserveWhitespace' attributes"

@ -6,9 +6,9 @@ import { ARIAPropertyDefinition } from 'aria-query';
* @internal
*/
export default {
custom_element_no_tag: {
code: 'custom-element-no-tag',
message: 'No custom element \'tag\' option was specified. To automatically register a custom element, specify a name with a hyphen in it, e.g. <svelte:options tag="my-thing"/>. To hide this warning, use <svelte:options tag={null}/>'
tag_option_deprecated: {
code: 'tag-option-deprecated',
message: "'tag' option is deprecated — use 'customElement' instead"
},
unused_export_let: (component: string, property: string) => ({
code: 'unused-export-let',
@ -32,7 +32,7 @@ export default {
}),
missing_custom_element_compile_options: {
code: 'missing-custom-element-compile-options',
message: "The 'tag' option is used when generating a custom element. Did you forget the 'customElement: true' compile option?"
message: "The 'customElement' option is used when generating a custom element. Did you forget the 'customElement: true' compile option?"
},
css_unused_selector: (selector: string) => ({
code: 'css-unused-selector',

@ -407,7 +407,7 @@ export default class Stylesheet {
});
}
render(file: string, should_transform_selectors: boolean) {
render(file: string) {
if (!this.has_styles) {
return { code: null, map: null };
}
@ -421,12 +421,10 @@ export default class Stylesheet {
}
});
if (should_transform_selectors) {
const max = Math.max(...this.children.map(rule => rule.get_max_amount_class_specificity_increased()));
this.children.forEach((child: (Atrule | Rule)) => {
child.transform(code, this.id, this.keyframes, max);
});
}
const max = Math.max(...this.children.map(rule => rule.get_max_amount_class_specificity_increased()));
this.children.forEach((child: (Atrule | Rule)) => {
child.transform(code, this.id, this.keyframes, max);
});
let c = 0;
this.children.forEach(child => {

@ -7,12 +7,11 @@ import { walk } from 'estree-walker';
import { extract_names, Scope } from 'periscopic';
import { invalidate } from './invalidate';
import Block from './Block';
import { ImportDeclaration, ClassDeclaration, FunctionExpression, Node, Statement, ObjectExpression, Expression } from 'estree';
import { ImportDeclaration, ClassDeclaration, Node, Statement, ObjectExpression, Expression } from 'estree';
import { apply_preprocessor_sourcemap } from '../../utils/mapped_code';
import { flatten } from '../../utils/flatten';
import check_enable_sourcemap from '../utils/check_enable_sourcemap';
import { push_array } from '../../utils/push_array';
import { regex_backslashes } from '../../utils/patterns';
export default function dom(
component: Component,
@ -25,9 +24,6 @@ export default function dom(
block.has_outro_method = true;
// prevent fragment being created twice (#1063)
if (options.customElement) block.chunks.create.push(b`this.c = @noop;`);
const body = [];
if (renderer.file_var) {
@ -35,7 +31,7 @@ export default function dom(
body.push(b`const ${renderer.file_var} = ${file};`);
}
const css = component.stylesheet.render(options.filename, !options.customElement);
const css = component.stylesheet.render(options.filename);
const css_sourcemap_enabled = check_enable_sourcemap(options.enableSourcemap, 'css');
@ -52,9 +48,8 @@ export default function dom(
const add_css = component.get_unique_name('add_css');
const should_add_css = (
!options.customElement &&
!!styles &&
options.css === 'injected'
(options.customElement || options.css === 'injected')
);
if (should_add_css) {
@ -519,91 +514,56 @@ export default function dom(
}
}
if (options.customElement) {
let init_props = x`@attribute_to_object(this.attributes)`;
if (uses_slots) {
init_props = x`{ ...${init_props}, $$slots: @get_custom_elements_slots(this) }`;
}
const declaration = b`
class ${name} extends @SvelteElement {
constructor(options) {
super();
${css.code && b`
const style = document.createElement('style');
style.textContent = \`${css.code.replace(regex_backslashes, '\\\\')}${css_sourcemap_enabled && options.dev ? `\n/*# sourceMappingURL=${css.map.toUrl()} */` : ''}\`
this.shadowRoot.appendChild(style)`}
const superclass = {
type: 'Identifier',
name: options.dev ? '@SvelteComponentDev' : '@SvelteComponent'
};
@init(this, { target: this.shadowRoot, props: ${init_props}, customElement: true }, ${definition}, ${has_create_fragment ? 'create_fragment' : 'null'}, ${not_equal}, ${prop_indexes}, null, ${dirty});
if (options) {
if (options.target) {
@insert(options.target, this, options.anchor);
}
const optional_parameters = [];
if (should_add_css) {
optional_parameters.push(add_css);
} else if (dirty) {
optional_parameters.push(x`null`);
}
if (dirty) {
optional_parameters.push(dirty);
}
${(props.length > 0 || uses_props || uses_rest) && b`
if (options.props) {
this.$set(options.props);
@flush();
}`}
}
}
const declaration = b`
class ${name} extends ${superclass} {
constructor(options) {
super(${options.dev && 'options'});
@init(this, options, ${definition}, ${has_create_fragment ? 'create_fragment' : 'null'}, ${not_equal}, ${prop_indexes}, ${optional_parameters});
${options.dev && b`@dispatch_dev("SvelteRegisterComponent", { component: this, tagName: "${name.name}", options, id: create_fragment.name });`}
}
`[0] as ClassDeclaration;
if (props.length > 0) {
declaration.body.body.push({
type: 'MethodDefinition',
kind: 'get',
static: true,
computed: false,
key: { type: 'Identifier', name: 'observedAttributes' },
value: x`function() {
return [${props.map(prop => x`"${prop.export_name}"`)}];
}` as FunctionExpression
});
}
`[0] as ClassDeclaration;
push_array(declaration.body.body, accessors);
body.push(declaration);
if (component.tag != null) {
body.push(b`
@_customElements.define("${component.tag}", ${name});
`);
}
} else {
const superclass = {
type: 'Identifier',
name: options.dev ? '@SvelteComponentDev' : '@SvelteComponent'
};
push_array(declaration.body.body, accessors);
body.push(declaration);
const optional_parameters = [];
if (should_add_css) {
optional_parameters.push(add_css);
} else if (dirty) {
optional_parameters.push(x`null`);
}
if (dirty) {
optional_parameters.push(dirty);
}
const declaration = b`
class ${name} extends ${superclass} {
constructor(options) {
super(${options.dev && 'options'});
@init(this, options, ${definition}, ${has_create_fragment ? 'create_fragment' : 'null'}, ${not_equal}, ${prop_indexes}, ${optional_parameters});
${options.dev && b`@dispatch_dev("SvelteRegisterComponent", { component: this, tagName: "${name.name}", options, id: create_fragment.name });`}
}
if (options.customElement) {
const props_str = writable_props.reduce((def, prop) => {
def[prop.export_name] = component.component_options.customElement?.props?.[prop.export_name] || {};
if (prop.is_boolean && !def[prop.export_name].type) {
def[prop.export_name].type = 'Boolean';
}
`[0] as ClassDeclaration;
push_array(declaration.body.body, accessors);
body.push(declaration);
return def;
}, {});
const slots_str = [...component.slots.keys()].map(key => `"${key}"`).join(',');
const accessors_str = accessors
.filter(accessor => !writable_props.some(prop => prop.export_name === accessor.key.name))
.map(accessor => `"${accessor.key.name}"`)
.join(',');
const use_shadow_dom = component.component_options.customElement?.shadow !== 'none' ? 'true' : 'false';
if (component.component_options.customElement?.tag) {
body.push(
b`@_customElements.define("${component.component_options.customElement.tag}", @create_custom_element(${name}, ${JSON.stringify(props_str)}, [${slots_str}], [${accessors_str}], ${use_shadow_dom}));`
);
} else {
body.push(b`@create_custom_element(${name}, ${JSON.stringify(props_str)}, [${slots_str}], [${accessors_str}], ${use_shadow_dom});`);
}
}
return { js: flatten(body), css };

@ -33,7 +33,7 @@ export default function ssr(
// TODO concatenate CSS maps
const css = options.customElement ?
{ code: null, map: null } :
component.stylesheet.render(options.filename, true);
component.stylesheet.render(options.filename);
const uses_rest = component.var_lookup.has('$$restProps');
const props = component.vars.filter(variable => !variable.module && variable.export_name);

@ -206,7 +206,10 @@ export interface AppendTarget {
export interface Var {
name: string;
export_name?: string; // the `bar` in `export { foo as bar }`
/** the `bar` in `export { foo as bar }` or `export let bar` */
export_name?: string;
/** true if assigned a boolean default value (`export let foo = true`) */
is_boolean?: boolean;
injected?: boolean;
module?: boolean;
mutated?: boolean;

@ -115,7 +115,7 @@ export default function tag(parser: Parser) {
: (regex_capital_letter.test(name[0]) || name === 'svelte:self' || name === 'svelte:component') ? 'InlineComponent'
: name === 'svelte:fragment' ? 'SlotTemplate'
: name === 'title' && parent_is_head(parser.stack) ? 'Title'
: name === 'slot' && !parser.customElement ? 'Slot' : 'Element';
: name === 'slot' ? 'Slot' : 'Element';
const element: TemplateNode = {
start,

@ -1,9 +1,10 @@
import { add_render_callback, flush, flush_render_callbacks, schedule_update, dirty_components } from './scheduler';
import { current_component, set_current_component } from './lifecycle';
import { blank_object, is_empty, is_function, run, run_all, noop } from './utils';
import { children, detach, start_hydrating, end_hydrating } from './dom';
import { children, detach, start_hydrating, end_hydrating, get_custom_elements_slots, insert } from './dom';
import { transition_in } from './transitions';
import { T$$ } from './types';
import { ComponentType } from './dev';
export function bind(component, name, callback) {
const index = component.$$.props[name];
@ -21,29 +22,27 @@ export function claim_component(block, parent_nodes) {
block && block.l(parent_nodes);
}
export function mount_component(component, target, anchor, customElement) {
export function mount_component(component, target, anchor) {
const { fragment, after_update } = component.$$;
fragment && fragment.m(target, anchor);
if (!customElement) {
// onMount happens before the initial afterUpdate
add_render_callback(() => {
const new_on_destroy = component.$$.on_mount.map(run).filter(is_function);
// if the component was destroyed immediately
// it will update the `$$.on_destroy` reference to `null`.
// the destructured on_destroy may still reference to the old array
if (component.$$.on_destroy) {
component.$$.on_destroy.push(...new_on_destroy);
} else {
// Edge case - component was destroyed immediately,
// most likely as a result of a binding initialising
run_all(new_on_destroy);
}
component.$$.on_mount = [];
});
}
// onMount happens before the initial afterUpdate
add_render_callback(() => {
const new_on_destroy = component.$$.on_mount.map(run).filter(is_function);
// if the component was destroyed immediately
// it will update the `$$.on_destroy` reference to `null`.
// the destructured on_destroy may still reference to the old array
if (component.$$.on_destroy) {
component.$$.on_destroy.push(...new_on_destroy);
} else {
// Edge case - component was destroyed immediately,
// most likely as a result of a binding initialising
run_all(new_on_destroy);
}
component.$$.on_mount = [];
});
after_update.forEach(add_render_callback);
}
@ -137,7 +136,7 @@ export function init(component, options, instance, create_fragment, not_equal, p
}
if (options.intro) transition_in(component.$$.fragment);
mount_component(component, options.target, options.anchor, options.customElement);
mount_component(component, options.target, options.anchor);
end_hydrating();
flush();
}
@ -148,59 +147,257 @@ export function init(component, options, instance, create_fragment, not_equal, p
export let SvelteElement;
if (typeof HTMLElement === 'function') {
SvelteElement = class extends HTMLElement {
$$: T$$;
$$set?: ($$props: any) => void;
constructor() {
private $$component?: SvelteComponent;
private $$connected = false;
private $$data = {};
private $$reflecting = false;
private $$props_definition: Record<string, CustomElementPropDefinition> = {};
private $$listeners: Record<string, Function[]> = {};
private $$listener_unsubscribe_fns = new Map<Function, Function>();
constructor(
private $$componentCtor: ComponentType,
private $$slots: string[],
use_shadow_dom: boolean
) {
super();
this.attachShadow({ mode: 'open' });
if (use_shadow_dom) {
this.attachShadow({ mode: 'open' });
}
}
addEventListener(type: string, listener: any, options?: any): void {
// We can't determine upfront if the event is a custom event or not, so we have to
// listen to both. If someone uses a custom event with the same name as a regular
// browser event, this fires twice - we can't avoid that.
this.$$listeners[type] = this.$$listeners[type] || [];
this.$$listeners[type].push(listener);
if (this.$$component) {
const unsub = this.$$component!.$on(type, listener);
this.$$listener_unsubscribe_fns.set(listener, unsub);
}
super.addEventListener(type, listener, options);
}
connectedCallback() {
const { on_mount } = this.$$;
this.$$.on_disconnect = on_mount.map(run).filter(is_function);
removeEventListener(type: string, listener: any, options?: any): void {
super.removeEventListener(type, listener, options);
if (this.$$component) {
const unsub = this.$$listener_unsubscribe_fns.get(listener);
if (unsub) {
unsub();
this.$$listener_unsubscribe_fns.delete(listener);
}
}
}
// @ts-ignore todo: improve typings
for (const key in this.$$.slotted) {
// @ts-ignore todo: improve typings
this.appendChild(this.$$.slotted[key]);
async connectedCallback() {
this.$$connected = true;
if (!this.$$component) {
// We wait one tick to let possible child slot elements be created/mounted
await Promise.resolve();
if (!this.$$connected) {
return;
}
function create_slot(name: string) {
return () => {
let node: HTMLSlotElement;
const obj = {
c: function create() {
node = document.createElement('slot');
if (name !== 'default') {
node.setAttribute('name', name);
}
},
m: function mount(target: HTMLElement, anchor?: HTMLElement) {
insert(target, node, anchor);
},
d: function destroy(detaching: boolean) {
if (detaching) {
detach(node);
}
}
};
return obj;
};
}
const $$slots: Record<string, any> = {};
const existing_slots = get_custom_elements_slots(this);
for (const name of this.$$slots) {
if (name in existing_slots) {
$$slots[name] = [create_slot(name)];
}
}
for (const attribute of this.attributes) {
// this.$$data takes precedence over this.attributes
const name = this.$$get_prop_name(attribute.name);
if (!(name in this.$$data)) {
this.$$data[name] = get_custom_element_value(name, attribute.value, this.$$props_definition, 'toProp');
}
}
this.$$component = new this.$$componentCtor({
target: this.shadowRoot || this,
props: {
...this.$$data,
$$slots,
$$scope: {
ctx: []
}
}
});
for (const type in this.$$listeners) {
for (const listener of this.$$listeners[type]) {
const unsub = this.$$component!.$on(type, listener);
this.$$listener_unsubscribe_fns.set(listener, unsub);
}
}
this.$$listeners = {};
}
}
attributeChangedCallback(attr, _oldValue, newValue) {
this[attr] = newValue;
// We don't need this when working within Svelte code, but for compatibility of people using this outside of Svelte
// and setting attributes through setAttribute etc, this is helpful
attributeChangedCallback(attr: string, _oldValue: any, newValue: any) {
if (this.$$reflecting) return;
attr = this.$$get_prop_name(attr);
this.$$data[attr] = get_custom_element_value(attr, newValue, this.$$props_definition, 'toProp');
this.$$component!.$set({ [attr]: this.$$data[attr] });
}
disconnectedCallback() {
run_all(this.$$.on_disconnect);
this.$$connected = false;
// In a microtask, because this could be a move within the DOM
Promise.resolve().then(() => {
if (!this.$$connected) {
this.$$component!.$destroy();
this.$$component = undefined;
}
});
}
$destroy() {
destroy_component(this, 1);
this.$destroy = noop;
private $$get_prop_name(attribute_name: string): string {
return Object.keys(this.$$props_definition).find(
key => this.$$props_definition[key].attribute === attribute_name ||
(!this.$$props_definition[key].attribute && key.toLowerCase() === attribute_name)
) || attribute_name;
}
};
}
$on(type, callback) {
// TODO should this delegate to addEventListener?
if (!is_function(callback)) {
return noop;
}
const callbacks = (this.$$.callbacks[type] || (this.$$.callbacks[type] = []));
callbacks.push(callback);
function get_custom_element_value(prop: string, value: any, props_definition: Record<string, CustomElementPropDefinition>, transform?: 'toAttribute' | 'toProp') {
const type = props_definition[prop]?.type;
value = type === 'Boolean' && typeof value !== 'boolean' ? value != null : value;
if (!transform || !props_definition[prop]) {
return value;
} else if (transform === 'toAttribute') {
switch (type) {
case 'Object':
case 'Array':
return value == null ? null : JSON.stringify(value);
case 'Boolean':
return value ? '' : null;
case 'Number':
return value == null ? null : value;
default:
return value;
}
} else {
switch (type) {
case 'Object':
case 'Array':
return value && JSON.parse(value);
case 'Boolean':
return value; // conversion already handled above
case 'Number':
return value != null ? +value : value;
default:
return value;
}
}
}
interface CustomElementPropDefinition {
attribute?: string;
reflect?: boolean;
type?: 'String' | 'Boolean' | 'Number' | 'Array' | 'Object';
}
return () => {
const index = callbacks.indexOf(callback);
if (index !== -1) callbacks.splice(index, 1);
};
/**
* @internal
*
* Turn a Svelte component into a custom element.
* @param Component A Svelte component constructor
* @param props_definition The props to observe
* @param slots The slots to create
* @param accessors Other accessors besides the ones for props the component has
* @param use_shadow_dom Whether to use shadow DOM
* @returns A custom element class
*/
export function create_custom_element(
Component: ComponentType,
props_definition: Record<string, CustomElementPropDefinition>,
slots: string[],
accessors: string[],
use_shadow_dom: boolean
) {
const Class = class extends SvelteElement {
constructor() {
super(Component, slots, use_shadow_dom);
this.$$props_definition = props_definition;
}
$set($$props) {
if (this.$$set && !is_empty($$props)) {
this.$$.skip_bound = true;
this.$$set($$props);
this.$$.skip_bound = false;
}
static get observedAttributes() {
return Object.keys(props_definition).map(key => (props_definition[key].attribute || key).toLowerCase());
}
};
Object.keys(props_definition).forEach((prop) => {
Object.defineProperty(Class.prototype, prop, {
get() {
return this.$$component && prop in this.$$component
? this.$$component[prop]
: this.$$data[prop];
},
set(value) {
value = get_custom_element_value(prop, value, props_definition);
this.$$data[prop] = value;
this.$$component?.$set({ [prop]: value });
if (props_definition[prop].reflect) {
this.$$reflecting = true;
const attribute_value = get_custom_element_value(prop, value, props_definition, 'toAttribute');
if (attribute_value == null) {
this.removeAttribute(prop);
} else {
this.setAttribute(
props_definition[prop].attribute || prop,
attribute_value as string
);
}
this.$$reflecting = false;
}
}
});
});
accessors.forEach(accessor => {
Object.defineProperty(Class.prototype, accessor, {
get() {
return this.$$component?.[accessor];
}
});
});
Component.element = Class as any;
return Class;
}
/**

@ -284,11 +284,14 @@ export class SvelteComponentTyped<
* <svelte:component this={componentOfCertainSubType} needsThisProp="hello" />
* ```
*/
export type ComponentType<Component extends SvelteComponentDev = SvelteComponentDev> = new (
export type ComponentType<Component extends SvelteComponentDev = SvelteComponentDev> = (new (
options: ComponentConstructorOptions<
Component extends SvelteComponentDev<infer Props> ? Props : Record<string, any>
>
) => Component;
) => Component) & {
/** The custom element version of the component. Only present if compiled with the `customElement` compiler option */
element?: typeof HTMLElement
};
/**
* Convenience type to get the props the given component expects. Example:

@ -1,4 +1,4 @@
<svelte:options tag="custom-element"/>
<svelte:options customElement="custom-element" />
<script>
export let name;
@ -7,4 +7,3 @@
<p>name: {name}</p>
<p>$$props: {JSON.stringify($$props)}</p>
<p>$$restProps: {JSON.stringify($$restProps)}</p>

@ -1,8 +1,10 @@
import * as assert from 'assert';
import { tick } from 'svelte';
import './main.svelte';
export default function (target) {
export default async function (target) {
target.innerHTML = '<custom-element name="world" answer="42" test="svelte"></custom-element>';
await tick();
const el = target.querySelector('custom-element');
assert.htmlEqual(el.shadowRoot.innerHTML, `

@ -0,0 +1,10 @@
<svelte:options customElement={null} />
<script>
import "./my-widget.svelte";
export let name;
</script>
<my-widget>
<p>default {name}</p>
</my-widget>

@ -0,0 +1,4 @@
<svelte:options customElement="my-widget" />
<slot>fallback</slot>
<slot name="named"><p>named fallback</p></slot>

@ -0,0 +1,22 @@
import * as assert from 'assert';
import { tick } from 'svelte';
import Component from './main.svelte';
export default async function (target) {
const component = new Component({ target, props: { name: 'slot' } });
await tick();
await tick();
const ce = target.querySelector('my-widget');
assert.htmlEqual(ce.shadowRoot.innerHTML, `
<slot></slot>
<p>named fallback</p>
`);
component.name = 'slot2';
assert.htmlEqual(ce.shadowRoot.innerHTML, `
<slot></slot>
<p>named fallback</p>
`);
}

@ -1,8 +1,10 @@
<svelte:options customElement="custom-element" />
<script>
let data = '';
let data = "";
if ($$slots.b) {
data = 'foo';
data = "foo";
}
export function getData() {
@ -12,20 +14,18 @@
function toString(data) {
const result = {};
const sortedKeys = Object.keys(data).sort();
sortedKeys.forEach(key => result[key] = data[key]);
sortedKeys.forEach((key) => (result[key] = data[key]));
return JSON.stringify(result);
}
</script>
<svelte:options tag="custom-element"/>
<slot></slot>
<slot name="a"></slot>
<slot />
<slot name="a" />
<p>$$slots: {toString($$slots)}</p>
{#if $$slots.b}
<div>
<slot name="b"></slot>
<slot name="b" />
</div>
{:else}
<p>Slot b is not available</p>
{/if}
{/if}

@ -1,11 +1,13 @@
import * as assert from 'assert';
import { tick } from 'svelte';
import './main.svelte';
export default function (target) {
export default async function (target) {
target.innerHTML = `
<custom-element><span slot="a">hello world</span><span>bye</span><span>world</span></custom-element>
<custom-element><span slot="a">hello world</span><span slot="b">hello world</span><span>bye world</span></custom-element>
`;
await tick();
const [a, b] = target.querySelectorAll('custom-element');

@ -0,0 +1,20 @@
<svelte:options customElement="custom-element" />
<script>
export let name;
export let events = [];
function action(_node, name) {
events.push(name);
return {
update(name) {
events.push(name);
},
destroy() {
events.push("destroy");
},
};
}
</script>
<div use:action={name}>action</div>

@ -0,0 +1,19 @@
import * as assert from 'assert';
import { tick } from 'svelte';
import './main.svelte';
export default async function (target) {
target.innerHTML = '<custom-element name="foo"></custom-element>';
await tick();
const el = target.querySelector('custom-element');
const events = el.events; // need to get the array reference, else it's gone when destroyed
assert.deepEqual(events, ['foo']);
el.name = 'bar';
await tick();
assert.deepEqual(events, ['foo', 'bar']);
target.innerHTML = '';
await tick();
assert.deepEqual(events, ['foo', 'bar', 'destroy']);
}

@ -0,0 +1,21 @@
<svelte:options
customElement={{
tag: "custom-element",
props: {
camelCase: { attribute: "camel-case" },
camelCase2: { reflect: true },
anArray: { attribute: "an-array", type: "Array", reflect: true },
},
}}
/>
<script>
export let camelCase;
export let camelCase2;
export let anArray;
</script>
<h1>{camelCase2} {camelCase}!</h1>
{#each anArray as item}
<p>{item}</p>
{/each}

@ -0,0 +1,25 @@
import * as assert from 'assert';
import { tick } from 'svelte';
import './main.svelte';
export default async function (target) {
target.innerHTML = '<custom-element camelcase2="Hello" camel-case="world" an-array="[1,2]"></custom-element>';
await tick();
const el = target.querySelector('custom-element');
assert.equal(el.shadowRoot.innerHTML, '<h1>Hello world!</h1> <p>1</p><p>2</p>');
el.setAttribute('camel-case', 'universe');
el.setAttribute('an-array', '[3,4]');
el.setAttribute('camelcase2', 'Hi');
await tick();
assert.equal(el.shadowRoot.innerHTML, '<h1>Hi universe!</h1> <p>3</p><p>4</p>');
assert.equal(target.innerHTML, '<custom-element camelcase2="Hi" camel-case="universe" an-array="[3,4]"></custom-element>');
el.camelCase = 'galaxy';
el.camelCase2 = 'Hey';
el.anArray = [5, 6];
await tick();
assert.equal(el.shadowRoot.innerHTML, '<h1>Hey galaxy!</h1> <p>5</p><p>6</p>');
assert.equal(target.innerHTML, '<custom-element camelcase2="Hey" camel-case="universe" an-array="[5,6]"></custom-element>');
}

@ -0,0 +1,14 @@
<svelte:options
customElement={{
tag: "custom-element",
props: {
name: { reflect: false, type: "String", attribute: "name" },
},
}}
/>
<script>
export let name;
</script>
<h1>Hello {name}!</h1>

@ -0,0 +1,13 @@
import * as assert from 'assert';
import { tick } from 'svelte';
import './main.svelte';
export default async function (target) {
target.innerHTML = '<custom-element name="world"></custom-element>';
await tick();
const el = target.querySelector('custom-element');
const h1 = el.shadowRoot.querySelector('h1');
assert.equal(h1.textContent, 'Hello world!');
}

@ -1,4 +1,4 @@
<svelte:options tag="custom-element"/>
<svelte:options customElement="custom-element" />
<script>
export function updateFoo(value) {

@ -1,8 +1,10 @@
import * as assert from 'assert';
import { tick } from 'svelte';
import './main.svelte';
export default async function (target) {
target.innerHTML = '<custom-element></custom-element>';
await tick();
const el = target.querySelector('custom-element');
await el.updateFoo(42);

@ -1,9 +1,9 @@
<svelte:options tag="custom-element"/>
<svelte:options customElement="custom-element" />
<span class='icon'></span>
<span class="icon" />
<style>
.icon::before {
content: '\ff'
content: "\ff";
}
</style>

@ -1,11 +1,10 @@
import * as assert from 'assert';
import CustomElement from './main.svelte';
export default function (target) {
new CustomElement({
target
});
import { tick } from 'svelte';
import './main.svelte';
export default async function (target) {
target.innerHTML = '<custom-element></custom-element>';
await tick();
const icon = target.querySelector('custom-element').shadowRoot.querySelector('.icon');
const before = getComputedStyle(icon, '::before');

@ -0,0 +1,9 @@
<svelte:options customElement="custom-element" />
<script>
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();
</script>
<button on:click={() => dispatch("custom", "foo")}>bubble click</button>

@ -0,0 +1,35 @@
import * as assert from 'assert';
import { tick } from 'svelte';
import './main.svelte';
export default async function (target) {
target.innerHTML = '<custom-element></custom-element>';
const el = target.querySelector('custom-element');
const events = [];
const custom_before = () => {
events.push('before');
};
const click_before = () => {
events.push('click_before');
};
el.addEventListener('custom', custom_before);
el.addEventListener('click', click_before);
await tick();
el.addEventListener('custom', e => {
events.push(e.detail);
});
el.addEventListener('click', () => {
events.push('click');
});
el.shadowRoot.querySelector('button').click();
assert.deepEqual(events, ['before', 'foo', 'click_before', 'click']);
el.removeEventListener('custom', custom_before);
el.removeEventListener('click', click_before);
el.shadowRoot.querySelector('button').click();
assert.deepEqual(events, ['before', 'foo', 'click_before', 'click', 'foo', 'click']);
}

@ -2,14 +2,14 @@ export default {
warnings: [{
code: 'avoid-is',
message: "The 'is' attribute is not supported cross-browser and should be avoided",
pos: 98,
pos: 109,
start: {
character: 98,
character: 109,
column: 8,
line: 7
},
end: {
character: 116,
character: 127,
column: 26,
line: 7
}

@ -1,7 +1,7 @@
<svelte:options tag="custom-element"/>
<svelte:options customElement="custom-element" />
<script>
import './custom-button.js';
import "./custom-button.js";
</script>
<button is="custom-button">click me</button>
<button is="custom-button">click me</button>

@ -1,11 +1,10 @@
import * as assert from 'assert';
import CustomElement from './main.svelte';
export default function (target) {
new CustomElement({
target
});
import { tick } from 'svelte';
import './main.svelte';
export default async function (target) {
target.innerHTML = '<custom-element></custom-element>';
await tick();
assert.equal(target.innerHTML, '<custom-element></custom-element>');
const el = target.querySelector('custom-element');

@ -1,11 +1,11 @@
<svelte:options tag="custom-element"/>
<svelte:options customElement="custom-element" />
<div>
<slot>
<p>default fallback content</p>
</slot>
<slot name='foo'>
<slot name="foo">
<p>foo fallback content</p>
</slot>
</div>

@ -1,11 +1,13 @@
import * as assert from 'assert';
import { tick } from 'svelte';
import './main.svelte';
export default function (target) {
export default async function (target) {
target.innerHTML = `
<custom-element>
<strong>slotted</strong>
</custom-element>`;
await tick();
const el = target.querySelector('custom-element');
@ -13,5 +15,5 @@ export default function (target) {
const [slot0, slot1] = div.children;
assert.equal(slot0.assignedNodes()[1], target.querySelector('strong'));
assert.equal(slot1.assignedNodes().length, 0);
assert.equal(slot1.innerHTML, 'foo fallback content');
}

@ -1,4 +1,4 @@
<svelte:options tag="custom-element"/>
<svelte:options customElement="custom-element" />
<script>
export let name;

@ -1,8 +1,10 @@
import * as assert from 'assert';
import { tick } from 'svelte';
import './main.svelte';
export default function (target) {
export default async function (target) {
target.innerHTML = '<custom-element name="world"></custom-element>';
await tick();
const el = target.querySelector('custom-element');
assert.equal(el.name, 'world');

@ -1,7 +0,0 @@
<svelte:options tag="my-counter"/>
<script>
export let count = 0;
</script>
<button on:click='{() => count += 1}'>count: {count}</button>

@ -1,10 +0,0 @@
<svelte:options tag="my-app"/>
<script>
import Counter from './Counter.svelte';
export let count;
</script>
<Counter bind:count/>
<p>clicked {count} times</p>

@ -1,17 +0,0 @@
import * as assert from 'assert';
import './main.svelte';
export default async function (target) {
target.innerHTML = '<my-app/>';
const el = target.querySelector('my-app');
const counter = el.shadowRoot.querySelector('my-counter');
const button = counter.shadowRoot.querySelector('button');
assert.equal(counter.count, 0);
assert.equal(counter.shadowRoot.innerHTML, '<button>count: 0</button>');
await button.dispatchEvent(new MouseEvent('click'));
assert.equal(counter.count, 1);
assert.equal(counter.shadowRoot.innerHTML, '<button>count: 1</button>');
}

@ -0,0 +1,19 @@
<svelte:options customElement="my-counter" />
<script>
import { getContext } from "svelte";
export let count = 0;
const context = getContext("context");
</script>
<slot />
<button on:click={() => (count += 1)}>count: {count}</button>
<p>Context {context}</p>
<style>
button {
color: red;
}
</style>

@ -0,0 +1,16 @@
<svelte:options customElement="my-app" />
<script>
import { setContext } from "svelte";
import Counter from "./Counter.svelte";
export let count;
export let counter;
setContext("context", "works");
</script>
<Counter bind:count bind:this={counter}>
<span>slot {count}</span>
</Counter>
<p>clicked {count} times</p>

@ -0,0 +1,24 @@
import * as assert from 'assert';
import { tick } from 'svelte';
import './main.svelte';
export default async function (target) {
target.innerHTML = '<my-app/>';
await tick();
const el = target.querySelector('my-app');
const button = el.shadowRoot.querySelector('button');
const span = el.shadowRoot.querySelector('span');
const p = el.shadowRoot.querySelector('p');
assert.equal(el.counter.count, 0);
assert.equal(button.innerHTML, 'count: 0');
assert.equal(span.innerHTML, 'slot 0');
assert.equal(p.innerHTML, 'Context works');
assert.equal(getComputedStyle(button).color, 'rgb(255, 0, 0)');
await button.dispatchEvent(new MouseEvent('click'));
assert.equal(el.counter.count, 1);
assert.equal(button.innerHTML, 'count: 1');
assert.equal(span.innerHTML, 'slot 1');
}

@ -1,4 +1,4 @@
<svelte:options tag="custom-element"/>
<svelte:options customElement="custom-element" />
<p>styled</p>

@ -1,12 +1,11 @@
import * as assert from 'assert';
import CustomElement from './main.svelte';
import { tick } from 'svelte';
import './main.svelte';
export default function (target) {
export default async function (target) {
target.innerHTML = '<p>unstyled</p>';
new CustomElement({
target
});
target.appendChild(document.createElement('custom-element'));
await tick();
const unstyled = target.querySelector('p');
const styled = target.querySelector('custom-element').shadowRoot.querySelector('p');

@ -1,7 +0,0 @@
<svelte:options tag="custom-element"/>
<script>
export let name;
</script>
<h1>Hello {name}!</h1>

@ -1,18 +0,0 @@
import * as assert from 'assert';
import CustomElement from './main.svelte';
export default function (target) {
new CustomElement({
target,
props: {
name: 'world'
}
});
assert.equal(target.innerHTML, '<custom-element></custom-element>');
const el = target.querySelector('custom-element');
const h1 = el.shadowRoot.querySelector('h1');
assert.equal(h1.textContent, 'Hello world!');
}

@ -1,4 +1,4 @@
<svelte:options tag="my-app"/>
<svelte:options customElement="my-app" />
<script>
export let foo;

@ -1,7 +1,8 @@
import * as assert from 'assert';
import { tick } from 'svelte';
import './main.svelte';
export default function (target) {
export default async function (target) {
const warnings = [];
const warn = console.warn;
@ -10,6 +11,7 @@ export default function (target) {
};
target.innerHTML = '<my-app foo=yes />';
await tick();
assert.deepEqual(warnings, [
"<my-app> was created without expected prop 'bar'"

@ -0,0 +1,13 @@
<svelte:options customElement={{ tag: "custom-element", shadow: "none" }} />
<script>
export let name;
</script>
<h1>Hello {name}!</h1>
<style>
h1 {
color: red;
}
</style>

@ -0,0 +1,16 @@
import * as assert from 'assert';
import { tick } from 'svelte';
import './main.svelte';
export default async function (target) {
target.innerHTML = '<custom-element name="world"></custom-element>';
await tick();
const el = target.querySelector('custom-element');
const h1 = el.querySelector('h1');
assert.equal(el.name, 'world');
assert.equal(el.shadowRoot, null);
assert.equal(h1.innerHTML, 'Hello world!');
assert.equal(getComputedStyle(h1).color, 'rgb(255, 0, 0)');
}

@ -1,17 +0,0 @@
export default {
warnings: [{
code: 'custom-element-no-tag',
message: "No custom element 'tag' option was specified. To automatically register a custom element, specify a name with a hyphen in it, e.g. <svelte:options tag=\"my-thing\"/>. To hide this warning, use <svelte:options tag={null}/>",
pos: 0,
start: {
character: 0,
column: 0,
line: 1
},
end: {
character: 0,
column: 0,
line: 1
}
}]
};

@ -1,5 +0,0 @@
<script>
export let name;
</script>
<h1>Hello {name}!</h1>

@ -1,12 +0,0 @@
import * as assert from 'assert';
import CustomElement from './main.svelte';
export default function (target) {
customElements.define('no-tag', CustomElement);
target.innerHTML = '<no-tag name="world"></no-tag>';
const el = target.querySelector('no-tag');
const h1 = el.shadowRoot.querySelector('h1');
assert.equal(h1.textContent, 'Hello world!');
}

@ -1,17 +0,0 @@
export default {
warnings: [{
code: 'custom-element-no-tag',
message: "No custom element 'tag' option was specified. To automatically register a custom element, specify a name with a hyphen in it, e.g. <svelte:options tag=\"my-thing\"/>. To hide this warning, use <svelte:options tag={null}/>",
pos: 0,
start: {
character: 0,
column: 0,
line: 1
},
end: {
character: 18,
column: 18,
line: 1
}
}]
};

@ -1,7 +0,0 @@
<svelte:options />
<script>
export let name;
</script>
<h1>Hello {name}!</h1>

@ -1,12 +0,0 @@
import * as assert from 'assert';
import CustomElement from './main.svelte';
export default function (target) {
customElements.define('no-tag', CustomElement);
target.innerHTML = '<no-tag name="world"></no-tag>';
const el = target.querySelector('no-tag');
const h1 = el.shadowRoot.querySelector('h1');
assert.equal(h1.textContent, 'Hello world!');
}

@ -1,5 +1,3 @@
<svelte:options tag={null} />
<script>
export let name;
</script>

@ -1,9 +1,11 @@
import * as assert from 'assert';
import { tick } from 'svelte';
import CustomElement from './main.svelte';
export default function (target) {
customElements.define('no-tag', CustomElement);
export default async function (target) {
customElements.define('no-tag', CustomElement.element);
target.innerHTML = '<no-tag name="world"></no-tag>';
await tick();
const el = target.querySelector('no-tag');
const h1 = el.shadowRoot.querySelector('h1');

@ -1,14 +1,14 @@
<svelte:options tag="my-app"/>
<svelte:options customElement="my-app" />
<script>
import { onMount } from 'svelte';
import { onMount } from "svelte";
export let prop = false;
export let propsInitialized;
export let wasCreated;
export let prop = false;
export let propsInitialized;
export let wasCreated;
onMount(() => {
propsInitialized = prop !== false;
wasCreated = true;
});
onMount(() => {
propsInitialized = prop !== false;
wasCreated = true;
});
</script>

@ -1,10 +1,14 @@
import * as assert from 'assert';
import { tick } from 'svelte';
import './main.svelte';
export default function (target) {
export default async function (target) {
target.innerHTML = '<my-app prop/>';
await tick();
const el = target.querySelector('my-app');
await tick();
assert.ok(el.wasCreated);
assert.ok(el.propsInitialized);
}

@ -1,8 +1,8 @@
<svelte:options tag="my-app"/>
<svelte:options customElement="my-app" />
<script>
import { onMount, onDestroy } from 'svelte';
import { onMount, onDestroy } from "svelte";
let el;
let parentEl;
@ -11,12 +11,12 @@
return () => {
parentEl.dataset.onMountDestroyed = true;
}
};
});
onDestroy(() => {
parentEl.dataset.destroyed = true;
})
});
</script>
<div bind:this={el}></div>
<div bind:this={el} />

@ -1,11 +1,15 @@
import * as assert from 'assert';
import { tick } from 'svelte';
import './main.svelte';
export default function (target) {
export default async function (target) {
target.innerHTML = '<my-app/>';
await tick();
const el = target.querySelector('my-app');
target.removeChild(el);
await tick();
assert.ok(target.dataset.onMountDestroyed);
assert.equal(target.dataset.destroyed, undefined);
assert.ok(target.dataset.destroyed);
}

@ -1,9 +1,9 @@
<svelte:options tag="custom-element"/>
<svelte:options customElement="custom-element" />
<script>
import './my-widget.svelte';
import "./my-widget.svelte";
export let items = ['a', 'b', 'c'];
export let items = ["a", "b", "c"];
export let flagged = false;
</script>

@ -1,4 +1,4 @@
<svelte:options tag="my-widget"/>
<svelte:options customElement="my-widget" />
<script>
export let items = [];
@ -7,6 +7,6 @@
</script>
<p>{items.length} items</p>
<p>{items.join(', ')}</p>
<p>{flag1 ? 'flagged (dynamic attribute)' : 'not flagged'}</p>
<p>{flag2 ? 'flagged (static attribute)' : 'not flagged'}</p>
<p>{items.join(", ")}</p>
<p>{flag1 ? "flagged (dynamic attribute)" : "not flagged"}</p>
<p>{flag2 ? "flagged (static attribute)" : "not flagged"}</p>

@ -1,10 +1,11 @@
import * as assert from 'assert';
import CustomElement from './main.svelte';
import { tick } from 'svelte';
import './main.svelte';
export default function (target) {
new CustomElement({
target
});
export default async function (target) {
target.innerHTML = '<custom-element></custom-element>';
await tick();
await tick();
assert.equal(target.innerHTML, '<custom-element></custom-element>');
@ -20,6 +21,7 @@ export default function (target) {
el.items = ['d', 'e', 'f', 'g', 'h'];
el.flagged = true;
await tick();
assert.equal(p1.textContent, '5 items');
assert.equal(p2.textContent, 'd, e, f, g, h');

@ -0,0 +1,25 @@
<svelte:options
customElement={{
tag: "custom-element",
props: { red: { reflect: true, type: "Boolean" } },
}}
/>
<script>
import "./my-widget.svelte";
export let red;
red;
</script>
<div>hi</div>
<p>hi</p>
<my-widget red white />
<style>
:host([red]) div {
color: red;
}
:host([white]) p {
color: white;
}
</style>

@ -0,0 +1,23 @@
<svelte:options
customElement={{
tag: "my-widget",
props: { red: { reflect: true } },
}}
/>
<script>
export let red = false;
red;
</script>
<div>hi</div>
<p>hi</p>
<style>
:host([red]) div {
color: red;
}
:host([white]) p {
color: white;
}
</style>

@ -0,0 +1,22 @@
import * as assert from 'assert';
import { tick } from 'svelte';
import './main.svelte';
export default async function (target) {
target.innerHTML = '<custom-element red white></custom-element>';
await tick();
await tick();
const ceRoot = target.querySelector('custom-element').shadowRoot;
const div = ceRoot.querySelector('div');
const p = ceRoot.querySelector('p');
assert.equal(getComputedStyle(div).color, 'rgb(255, 0, 0)');
assert.equal(getComputedStyle(p).color, 'rgb(255, 255, 255)');
const innerRoot = ceRoot.querySelector('my-widget').shadowRoot;
const innerDiv = innerRoot.querySelector('div');
const innerP = innerRoot.querySelector('p');
assert.equal(getComputedStyle(innerDiv).color, 'rgb(255, 0, 0)');
assert.equal(getComputedStyle(innerP).color, 'rgb(255, 255, 255)');
}

@ -350,8 +350,7 @@ export async function executeBrowserTest(browser, launchPuppeteer, additionalAss
const page = await browser.newPage();
page.on('console', (type) => {
// @ts-ignore -- TODO: Fix type
console[type._type](type._text);
console[type.type()](type.text());
});
page.on('error', error => {

@ -1,7 +1,9 @@
/* generated by Svelte vX.Y.Z */
import {
SvelteElement,
attribute_to_object,
SvelteComponent,
append_styles,
attr,
create_custom_element,
detach,
element,
init,
@ -10,6 +12,10 @@ import {
safe_not_equal
} from "svelte/internal";
function add_css(target) {
append_styles(target, "svelte-10axo0s", "div.svelte-10axo0s{animation:svelte-10axo0s-foo 1s}@keyframes svelte-10axo0s-foo{0%{opacity:0}100%{opacity:1}}");
}
function create_fragment(ctx) {
let div;
@ -17,7 +23,7 @@ function create_fragment(ctx) {
c() {
div = element("div");
div.textContent = "fades in";
this.c = noop;
attr(div, "class", "svelte-10axo0s");
},
m(target, anchor) {
insert(target, div, anchor);
@ -31,34 +37,12 @@ function create_fragment(ctx) {
};
}
class Component extends SvelteElement {
class Component extends SvelteComponent {
constructor(options) {
super();
const style = document.createElement('style');
style.textContent = `div{animation:foo 1s}@keyframes foo{0%{opacity:0}100%{opacity:1}}`;
this.shadowRoot.appendChild(style);
init(
this,
{
target: this.shadowRoot,
props: attribute_to_object(this.attributes),
customElement: true
},
null,
create_fragment,
safe_not_equal,
{},
null
);
if (options) {
if (options.target) {
insert(options.target, this, options.anchor);
}
}
init(this, options, null, create_fragment, safe_not_equal, {}, add_css);
}
}
customElements.define("custom-element", Component);
export default Component;
customElements.define("custom-element", create_custom_element(Component, {}, [], [], true));
export default Component;

@ -1,4 +1,4 @@
<svelte:options tag="custom-element"/>
<svelte:options customElement="custom-element" />
<div>fades in</div>
@ -8,7 +8,11 @@
}
@keyframes foo {
0% { opacity: 0; }
100% { opacity: 1; }
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
</style>

@ -1,6 +1,7 @@
{
"extends": "../tsconfig.json",
"include": ["."],
"exclude": ["./**/_output/**/*"],
"compilerOptions": {
"allowJs": true,

@ -1 +1 @@
<svelte:options tag="custom-element" />
<svelte:options customElement="custom-element" />

@ -2,10 +2,10 @@
{
"code": "missing-custom-element-compile-options",
"end": {
"column": 36,
"column": 46,
"line": 1
},
"message": "The 'tag' option is used when generating a custom element. Did you forget the 'customElement: true' compile option?",
"message": "The 'customElement' option is used when generating a custom element. Did you forget the 'customElement: true' compile option?",
"start": {
"column": 16,
"line": 1

@ -1 +1 @@
<svelte:options tag="custom-element"/>
<svelte:options customElement="custom-element" />

@ -1,12 +1,12 @@
[{
"code": "missing-custom-element-compile-options",
"message": "The 'tag' option is used when generating a custom element. Did you forget the 'customElement: true' compile option?",
"message": "The 'customElement' option is used when generating a custom element. Did you forget the 'customElement: true' compile option?",
"start": {
"line": 1,
"column": 16
},
"end": {
"line": 1,
"column": 36
"column": 46
}
}]

@ -1 +1 @@
<svelte:options tag="custom-element"/>
<svelte:options customElement="custom-element" />

@ -7,6 +7,6 @@
},
"end": {
"line": 1,
"column": 29
"column": 39
}
}]

@ -1 +1 @@
<svelte:options tag="invalid"/>
<svelte:options customElement="invalid" />

Loading…
Cancel
Save