Skip to content

feat: custom elements rework #8457

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 42 commits into from
May 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
52f911c
wip
dummdidumm Apr 5, 2023
1131584
handle boolean attributes
dummdidumm Apr 5, 2023
82de3f6
introduce svelte:option cePropsDefinition
dummdidumm Apr 6, 2023
a4102f0
lint, cleanup, fix test
dummdidumm Apr 6, 2023
00d0405
handle dynamic slot content
dummdidumm Apr 6, 2023
07af512
inline styles for custom elements mode
dummdidumm Apr 6, 2023
62e6d06
remove unused styles param
dummdidumm Apr 6, 2023
868fb23
mount and render after a tick
dummdidumm Apr 11, 2023
a31d4a5
use $set, remove lowercase property handling in favor of attribute co…
dummdidumm Apr 11, 2023
931b7f5
cePropsDefinition -> ceProps
dummdidumm Apr 11, 2023
1241f43
test for context
dummdidumm Apr 11, 2023
765023d
fix: html space entities lost in component slot (#8464)
xxkl1 Apr 11, 2023
75aec41
breaking: send in/out to transition fn (#8318)
tivac Apr 11, 2023
c729829
chore: remove node<14 tests (#8482)
dummdidumm Apr 11, 2023
eedacc9
chore: simplify Svelte 4 CI (#8487)
benmccann Apr 12, 2023
abd760d
chore: bump engines field (#8489)
benmccann Apr 12, 2023
1e2cfa4
chore: upgrade to TypeScript 5 (#8488)
benmccann Apr 12, 2023
42e0f7d
chore: Svelte 4 dependency upgrades (#8486)
benmccann Apr 12, 2023
c9ccd6e
chore: upgrade rollup (#8491)
benmccann Apr 12, 2023
149c100
Merge branch 'version-4' into custom-elements-rework
dummdidumm Apr 12, 2023
96e9768
handle event listener registration before mount; handle unregister
dummdidumm Apr 12, 2023
840a7be
implement shadowdom option
dummdidumm Apr 12, 2023
573784c
chore: run fewer CI jobs (#8496)
benmccann Apr 13, 2023
d6bcddd
breaking: improve types for `createEventDispatcher` (#7224)
ivanhofer Apr 14, 2023
56a6738
breaking: conditional ActionReturn type if Parameter is void (#7442)
tanhauhau Apr 14, 2023
9460616
feat: add `a11y-no-static-element-interactions` compiler rule (#8251)
timmcca-be Apr 14, 2023
88728e3
fix: bind null option and input values consistently (#8328)
theodorejb Apr 14, 2023
d1a9722
feat: add a11y `no-noninteractive-element-interactions` (#8391)
ngtr6788 Apr 14, 2023
e790740
Merge branch 'version-4' into custom-elements-rework
dummdidumm Apr 14, 2023
daadae9
changelog
dummdidumm Apr 14, 2023
39333b1
chore: remove Node 8 and 10 logic (#8503)
baseballyama Apr 15, 2023
662804e
chore: produce single bundle for runtime with multiple entrypoints (#…
gtm-nayan Apr 18, 2023
350c6c3
breaking: update onMount type definition to prevent async function re…
chrskerr Apr 18, 2023
1f93e30
Merge branch 'version-4' into custom-elements-rework
dummdidumm Apr 18, 2023
9eb73e0
Merge branch 'version-4' into custom-elements-rework
dummdidumm Apr 18, 2023
b63a6aa
Merge branch 'custom-elements-rework' of https://github.com/dummdidum…
dummdidumm Apr 18, 2023
00e6df8
add custom element version as static property for later custom regist…
dummdidumm Apr 19, 2023
1e82718
Merge branch 'version-4' into custom-elements-rework
dummdidumm Apr 28, 2023
5804b69
ceProps -> customElement.props, tag -> customElement/customElement.ta…
dummdidumm May 2, 2023
ad00392
lint, tests
dummdidumm May 2, 2023
424fcbc
hide from public type definition
dummdidumm May 2, 2023
8fe3c3a
fix rollup config
dummdidumm May 2, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,8 @@ module.exports = {
'estree'
],
'svelte3/compiler': require('./compiler')
},
rules: {
'@typescript-eslint/no-non-null-assertion': 'off'
}
};
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
12 changes: 11 additions & 1 deletion elements/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
5 changes: 4 additions & 1 deletion rollup.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions site/content/docs/03-template-syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -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>`
Expand Down
37 changes: 32 additions & 5 deletions site/content/docs/04-run-time.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
```

---
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
120 changes: 97 additions & 23 deletions src/compiler/compile/Component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -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 = /^[/\\]/;
Expand Down Expand Up @@ -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;
}
Expand All @@ -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);
}

Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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);
Expand Down
20 changes: 17 additions & 3 deletions src/compiler/compile/compiler_errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}'?)` : '')
Expand All @@ -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"
Expand Down
8 changes: 4 additions & 4 deletions src/compiler/compile/compiler_warnings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down
Loading