diff --git a/elements/index.d.ts b/elements/index.d.ts index e27916fac9..67f3b8c691 100644 --- a/elements/index.d.ts +++ b/elements/index.d.ts @@ -1598,8 +1598,14 @@ export interface SvelteHTMLElements { 'svelte:body': HTMLAttributes; 'svelte:fragment': { slot?: string }; 'svelte:options': { - tag?: string | null | undefined; - ceProps?: Record | undefined, + customElement?: string | undefined | { + tag: string; + shadow?: 'open' | 'none' | undefined; + props?: Record | undefined; + }; + immutable?: boolean | undefined; + accessors?: boolean | undefined; + namespace?: string | undefined; [name: string]: any }; 'svelte:head': { [name: string]: any }; diff --git a/site/content/docs/03-template-syntax.md b/site/content/docs/03-template-syntax.md index 170c303b85..a59b872691 100644 --- a/site/content/docs/03-template-syntax.md +++ b/site/content/docs/03-template-syntax.md @@ -1825,10 +1825,10 @@ The `` 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 - + ``` ### `` diff --git a/site/content/docs/04-run-time.md b/site/content/docs/04-run-time.md index 440d8aeccf..5021bc14f9 100644 --- a/site/content/docs/04-run-time.md +++ b/site/content/docs/04-run-time.md @@ -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 `` [element](/docs#template-syntax-svelte-options). ```sv - + + +... +``` + 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 `` element is inside an `{#if ...}` block. Similarly, including a `` 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 diff --git a/site/content/tutorial/16-special-elements/09-svelte-options/text.md b/site/content/tutorial/16-special-elements/09-svelte-options/text.md index 1a0105a09a..2783945b76 100644 --- a/site/content/tutorial/16-special-elements/09-svelte-options/text.md +++ b/site/content/tutorial/16-special-elements/09-svelte-options/text.md @@ -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. diff --git a/src/compiler/compile/Component.ts b/src/compiler/compile/Component.ts index 313df0fcc7..ce1afc5300 100644 --- a/src/compiler/compile/Component.ts +++ b/src/compiler/compile/Component.ts @@ -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'; @@ -42,12 +42,14 @@ import Tag from './nodes/shared/Tag'; interface ComponentOptions { namespace?: string; - tag?: string; immutable?: boolean; accessors?: boolean; preserveWhitespace?: boolean; - ceProps?: Record; - shadowdom?: 'open' | 'none'; + customElement?: { + tag: string | null; + shadow?: 'open' | 'none'; + props?: Record; + }; } const regex_leading_directory_separator = /^[/\\]/; @@ -169,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; } @@ -1565,72 +1558,99 @@ function process_component_options(component: Component, nodes) { if (attribute.type === 'Attribute') { const { name } = attribute; - switch (name) { - case 'tag': { - const tag = get_value(attribute, compiler_errors.invalid_tag_attribute); - - 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); - } + function parse_tag(attribute: Attribute, tag: string) { + if (typeof tag !== 'string' && tag !== null) { + return component.error(attribute, compiler_errors.invalid_tag_attribute); + } - component_options.tag = tag; - break; + if (tag && !regex_valid_tag_name.test(tag)) { + return component.error(attribute, compiler_errors.invalid_tag_property); } - case 'shadowdom': { - const shadowdom = get_value(attribute, compiler_errors.invalid_shadowdom_attribute); + if (tag && !component.compile_options.customElement) { + component.warn(attribute, compiler_warnings.missing_custom_element_compile_options); + } - if (shadowdom !== 'open' && shadowdom !== 'none') { - return component.error(attribute, compiler_errors.invalid_shadowdom_attribute); - } + component_options.customElement = component_options.customElement || {} as any; + component_options.customElement.tag = tag; + } - component_options.shadowdom = shadowdom; + switch (name) { + case 'tag': { + component.warn(attribute, compiler_warnings.tag_option_deprecated) + parse_tag(attribute, get_value(attribute, compiler_errors.invalid_tag_attribute)); break; } - case 'ceProps': { - const error = () => component.error(attribute, compiler_errors.invalid_ceProps_attribute); + case 'customElement': { + component_options.customElement = component_options.customElement || {} as any; + const { value } = attribute; - const chunk = value[0]; - component_options.ceProps = {}; - if (!chunk) { + 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 (value.length > 1 || chunk.expression?.type !== 'ObjectExpression') { - return error(); + 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); } - const object = chunk.expression as ObjectExpression; - for (const property of object.properties) { - if (property.type !== 'Property' || property.computed || property.key.type !== 'Identifier' || property.value.type !== 'ObjectExpression') { + 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.ceProps[property.key.name] = {}; - for (const prop of property.value.properties) { - if (prop.type !== 'Property' || prop.computed || prop.key.type !== 'Identifier' || prop.value.type !== 'Literal') { + + 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(); } - 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] = {}; + 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; } - component_options.ceProps[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; + } + break; } @@ -1664,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); diff --git a/src/compiler/compile/compiler_errors.ts b/src/compiler/compile/compiler_errors.ts index e4f1e037b7..c276b98278 100644 --- a/src/compiler/compile/compiler_errors.ts +++ b/src/compiler/compile/compiler_errors.ts @@ -202,18 +202,23 @@ 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_shadowdom_attribute: { - code: 'invalid-shadowdom-attribute', - message: "'shadowdom' must be either 'open' or 'none'" + invalid_shadow_attribute: { + code: 'invalid-shadow-attribute', + message: "'shadow' must be either 'open' or 'none'" }, - invalid_ceProps_attribute: { - code: 'invalid-ceProps-attribute', - message: "'ceProps' must be a statically analyzable object literal of the form " + - "'{ prop: { attribute?: string; type?: 'String' | 'Boolean' | 'Number' | 'Array' | 'Object', reflect?: boolean; } }'" + 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', @@ -227,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: ' unknown attribute' - }, + message: ` unknown attribute '${name}'` + }), invalid_options_attribute: { code: 'invalid-options-attribute', message: " can only have static 'tag', 'namespace', 'accessors', 'immutable' and 'preserveWhitespace' attributes" diff --git a/src/compiler/compile/compiler_warnings.ts b/src/compiler/compile/compiler_warnings.ts index 2138e81213..c9ba72ac9d 100644 --- a/src/compiler/compile/compiler_warnings.ts +++ b/src/compiler/compile/compiler_warnings.ts @@ -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. . To hide this warning, use ' + 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', diff --git a/src/compiler/compile/render_dom/index.ts b/src/compiler/compile/render_dom/index.ts index cfa9bfd226..d2188c96a1 100644 --- a/src/compiler/compile/render_dom/index.ts +++ b/src/compiler/compile/render_dom/index.ts @@ -544,7 +544,7 @@ export default function dom( if (options.customElement) { const props_str = writable_props.reduce((def, prop) => { - def[prop.export_name] = component.component_options.ceProps?.[prop.export_name] || {}; + 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'; } @@ -555,11 +555,11 @@ export default function dom( .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.shadowdom !== 'none' ? 'true' : 'false'; + const use_shadow_dom = component.component_options.customElement?.shadow !== 'none' ? 'true' : 'false'; - if (component.tag != null) { + if (component.component_options.customElement?.tag) { body.push( - b`@_customElements.define("${component.tag}", @create_custom_element(${name}, ${JSON.stringify(props_str)}, [${slots_str}], [${accessors_str}], ${use_shadow_dom}));` + 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});`); diff --git a/src/runtime/internal/Component.ts b/src/runtime/internal/Component.ts index 4cc5f4f85e..7bf1532ddc 100644 --- a/src/runtime/internal/Component.ts +++ b/src/runtime/internal/Component.ts @@ -291,11 +291,12 @@ if (typeof HTMLElement === 'function') { } function get_custom_element_value(prop: string, value: any, props_definition: Record, transform?: 'toAttribute' | 'toProp') { - value = props_definition[prop]?.type === 'Boolean' && typeof value !== 'boolean' ? value != null : value; + 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 (props_definition[prop].type) { + switch (type) { case 'Object': case 'Array': return value == null ? null : JSON.stringify(value); @@ -307,7 +308,7 @@ function get_custom_element_value(prop: string, value: any, props_definition: Re return value; } } else { - switch (props_definition[prop].type) { + switch (type) { case 'Object': case 'Array': return value && JSON.parse(value); @@ -322,9 +323,9 @@ function get_custom_element_value(prop: string, value: any, props_definition: Re } interface CustomElementPropDefinition { + attribute?: string; reflect?: boolean; type?: 'String' | 'Boolean' | 'Number' | 'Array' | 'Object'; - attribute?: string; } /** diff --git a/src/runtime/internal/dev.ts b/src/runtime/internal/dev.ts index 5e9895d95c..132b1e4bbd 100644 --- a/src/runtime/internal/dev.ts +++ b/src/runtime/internal/dev.ts @@ -284,11 +284,11 @@ export class SvelteComponentTyped< * * ``` */ -export type ComponentType = new ( +export type ComponentType = (new ( options: ComponentConstructorOptions< Component extends SvelteComponentDev ? Props : Record > -) => Component & { +) => Component) & { /** The custom element version of the component. Only present if compiled with the `customElement` compiler option */ element?: typeof HTMLElement }; diff --git a/test/custom-elements/samples/$$props/main.svelte b/test/custom-elements/samples/$$props/main.svelte index 68931e22db..22d4db74b1 100644 --- a/test/custom-elements/samples/$$props/main.svelte +++ b/test/custom-elements/samples/$$props/main.svelte @@ -1,4 +1,4 @@ - + - - - - + +

$$slots: {toString($$slots)}

{#if $$slots.b}
- +
{:else}

Slot b is not available

-{/if} \ No newline at end of file +{/if} diff --git a/test/custom-elements/samples/action/main.svelte b/test/custom-elements/samples/action/main.svelte index 1f8f5fe7e6..0d88504b87 100644 --- a/test/custom-elements/samples/action/main.svelte +++ b/test/custom-elements/samples/action/main.svelte @@ -1,4 +1,4 @@ - + - \ No newline at end of file + diff --git a/test/custom-elements/samples/html-slots/main.svelte b/test/custom-elements/samples/html-slots/main.svelte index 91f1fb800e..a894db5f76 100644 --- a/test/custom-elements/samples/html-slots/main.svelte +++ b/test/custom-elements/samples/html-slots/main.svelte @@ -1,11 +1,11 @@ - +

default fallback content

- +

foo fallback content

diff --git a/test/custom-elements/samples/html/main.svelte b/test/custom-elements/samples/html/main.svelte index 0931535a18..fba08ac269 100644 --- a/test/custom-elements/samples/html/main.svelte +++ b/test/custom-elements/samples/html/main.svelte @@ -1,4 +1,4 @@ - + - -

Hello {name}!

diff --git a/test/custom-elements/samples/no-svelte-options/test.js b/test/custom-elements/samples/no-svelte-options/test.js deleted file mode 100644 index f619c4232b..0000000000 --- a/test/custom-elements/samples/no-svelte-options/test.js +++ /dev/null @@ -1,15 +0,0 @@ -import * as assert from 'assert'; -import { tick } from 'svelte'; -import CustomElement from './main.svelte'; -import { create_custom_element } from 'svelte/internal'; - -export default async function (target) { - customElements.define('no-tag', create_custom_element(CustomElement, {name: {}}, [], [], true)); - target.innerHTML = ''; - await tick(); - - const el = target.querySelector('no-tag'); - const h1 = el.shadowRoot.querySelector('h1'); - - assert.equal(h1.textContent, 'Hello world!'); -} diff --git a/test/custom-elements/samples/no-tag-warning/_config.js b/test/custom-elements/samples/no-tag-warning/_config.js deleted file mode 100644 index fb476a7b5b..0000000000 --- a/test/custom-elements/samples/no-tag-warning/_config.js +++ /dev/null @@ -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. . To hide this warning, use ", - pos: 0, - start: { - character: 0, - column: 0, - line: 1 - }, - end: { - character: 18, - column: 18, - line: 1 - } - }] -}; diff --git a/test/custom-elements/samples/no-tag-warning/main.svelte b/test/custom-elements/samples/no-tag-warning/main.svelte deleted file mode 100644 index 4f7cdc52ca..0000000000 --- a/test/custom-elements/samples/no-tag-warning/main.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - - - -

Hello {name}!

diff --git a/test/custom-elements/samples/no-tag-warning/test.js b/test/custom-elements/samples/no-tag-warning/test.js deleted file mode 100644 index 4558f195f9..0000000000 --- a/test/custom-elements/samples/no-tag-warning/test.js +++ /dev/null @@ -1,15 +0,0 @@ -import * as assert from 'assert'; -import { tick } from 'svelte'; -import CustomElement from './main.svelte'; -import { create_custom_element } from 'svelte/internal'; - -export default async function (target) { - customElements.define('no-tag', create_custom_element(CustomElement, { name: {}}, [], [], true)); - target.innerHTML = ''; - await tick(); - - const el = target.querySelector('no-tag'); - const h1 = el.shadowRoot.querySelector('h1'); - - assert.equal(h1.textContent, 'Hello world!'); -} diff --git a/test/custom-elements/samples/no-tag/main.svelte b/test/custom-elements/samples/no-tag/main.svelte index 031bd93694..538dc970e9 100644 --- a/test/custom-elements/samples/no-tag/main.svelte +++ b/test/custom-elements/samples/no-tag/main.svelte @@ -1,5 +1,3 @@ - - diff --git a/test/custom-elements/samples/oncreate/main.svelte b/test/custom-elements/samples/oncreate/main.svelte index e22d101eca..f316036069 100644 --- a/test/custom-elements/samples/oncreate/main.svelte +++ b/test/custom-elements/samples/oncreate/main.svelte @@ -1,4 +1,4 @@ - + -
+
diff --git a/test/custom-elements/samples/props/main.svelte b/test/custom-elements/samples/props/main.svelte index cf47b436b5..5dbedd77af 100644 --- a/test/custom-elements/samples/props/main.svelte +++ b/test/custom-elements/samples/props/main.svelte @@ -1,9 +1,9 @@ - + diff --git a/test/custom-elements/samples/props/my-widget.svelte b/test/custom-elements/samples/props/my-widget.svelte index 970acf84b2..3fb3d95c58 100644 --- a/test/custom-elements/samples/props/my-widget.svelte +++ b/test/custom-elements/samples/props/my-widget.svelte @@ -1,4 +1,4 @@ - +

{items.length} items

-

{items.join(', ')}

-

{flag1 ? 'flagged (dynamic attribute)' : 'not flagged'}

-

{flag2 ? 'flagged (static attribute)' : 'not flagged'}

+

{items.join(", ")}

+

{flag1 ? "flagged (dynamic attribute)" : "not flagged"}

+

{flag2 ? "flagged (static attribute)" : "not flagged"}

diff --git a/test/custom-elements/samples/reflect-attributes/main.svelte b/test/custom-elements/samples/reflect-attributes/main.svelte index a1d3f67fbc..0e36f0143c 100644 --- a/test/custom-elements/samples/reflect-attributes/main.svelte +++ b/test/custom-elements/samples/reflect-attributes/main.svelte @@ -1,6 +1,8 @@