Skip to content

Commit 657f113

Browse files
authored
feat: add ability to extend custom element class (#8991)
This should help everyone who has special needs and use cases around custom elements. Since Svelte components are wrapped and only run on connectedCallback, it makes sense to expose the custom element class for modification before that. - fixes #8954 / closes #8955 - use extend to attach the function manually and save possible values to a prop - closes #8473 / closes #4168 - use extend to set the proper static attribute and then call attachInternals in the constructor - closes #8472 - use extend to attach anything custom you need - closes #3091 - pass `this` to a prop of your choice and use it inside your component - add some doc for #8987
1 parent 4bbb545 commit 657f113

File tree

8 files changed

+119
-19
lines changed

8 files changed

+119
-19
lines changed

.changeset/green-cats-matter.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': minor
3+
---
4+
5+
feat: add ability to extend custom element class

documentation/docs/04-compiler-and-api/04-custom-elements-api.md

+43-6
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,28 @@ console.log(el.name);
5555
el.name = 'everybody';
5656
```
5757

58+
## Component lifecycle
59+
60+
Custom elements are created from Svelte components using a wrapper approach. This means the inner Svelte component has no knowledge that it is a custom element. The custom element wrapper takes care of handling its lifecycle appropriately.
61+
62+
When a custom element is created, the Svelte component it wraps is _not_ created right away. It is only created in the next tick after the `connectedCallback` is invoked. Properties assigned to the custom element before it is inserted into the DOM are temporarily saved and then set on component creation, so their values are not lost. The same does not work for invoking exported functions on the custom element though, they are only available after the element has mounted. If you need to invoke functions before component creation, you can work around it by using the [`extend` option](#component-options).
63+
64+
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.
65+
66+
The inner Svelte component is destroyed in the next tick after the `disconnectedCallback` is invoked.
67+
5868
## Component options
5969

60-
When constructing a custom element, you can tailor several aspects by defining `customElement` as an object within `<svelte:options>` since Svelte 4. 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 (note that styles are then no longer encapsulated, and you can't use slots), and a `props` option, which offers the following settings:
70+
When constructing a custom element, you can tailor several aspects by defining `customElement` as an object within `<svelte:options>` since Svelte 4. This object may contain the following properties:
6171

62-
- `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>"`.
63-
- `reflect: boolean`: By default, updated prop values do not reflect back to the DOM. To enable this behavior, set `reflect: true`.
64-
- `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"`
72+
- `tag`: the mandatory `tag` property for the custom element's name
73+
- `shadow`: an optional property that can be set to `"none"` to forgo shadow root creation. Note that styles are then no longer encapsulated, and you can't use slots
74+
- `props`: an optional property to modify certain details and behaviors of your component's properties. It offers the following settings:
75+
- `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>"`.
76+
- `reflect: boolean`: By default, updated prop values do not reflect back to the DOM. To enable this behavior, set `reflect: true`.
77+
- `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"`
78+
You don't need to list all properties, those not listed will use the default settings.
79+
- `extend`: an optional property which expects a function as its argument. It is passed the custom element class generated by Svelte and expects you to return a custom element class. This comes in handy if you have very specific requirements to the life cycle of the custom element or want to enhance the class to for example use [ElementInternals](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals#examples) for better HTML form integration.
6580

6681
```svelte
6782
<svelte:options
@@ -70,12 +85,35 @@ When constructing a custom element, you can tailor several aspects by defining `
7085
shadow: 'none',
7186
props: {
7287
name: { reflect: true, type: 'Number', attribute: 'element-index' }
88+
},
89+
extend: (customElementConstructor) => {
90+
// Extend the class so we can let it participate in HTML forms
91+
return class extends customElementConstructor {
92+
static formAssociated = true;
93+
94+
constructor() {
95+
super();
96+
this.attachedInternals = this.attachInternals();
97+
}
98+
99+
// Add the function here, not below in the component so that
100+
// it's always available, not just when the inner Svelte component
101+
// is mounted
102+
randomIndex() {
103+
this.elementIndex = Math.random();
104+
}
105+
};
73106
}
74107
}}
75108
/>
76109
77110
<script>
78111
export let elementIndex;
112+
export let attachedInternals;
113+
// ...
114+
function check() {
115+
attachedInternals.checkValidity();
116+
}
79117
</script>
80118
81119
...
@@ -91,5 +129,4 @@ Custom elements can be a useful way to package components for consumption in a n
91129
- 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
92130
- 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
93131
- Polyfills are required to support older browsers
94-
95-
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.
132+
- You can use Svelte's context feature between regular Svelte components within a custom element, but you can't use them across custom elements. In other words, you can't use `setContext` on a parent custom element and read that with `getContext` in a child custom element.

packages/svelte/elements.d.ts

+3
Original file line numberDiff line numberDiff line change
@@ -1676,6 +1676,9 @@ export interface SvelteHTMLElements {
16761676
}
16771677
>
16781678
| undefined;
1679+
extend?: (
1680+
svelteCustomElementClass: new () => HTMLElement
1681+
) => new () => HTMLElement | undefined;
16791682
};
16801683
immutable?: boolean | undefined;
16811684
accessors?: boolean | undefined;

packages/svelte/src/compiler/compile/Component.js

+15-1
Original file line numberDiff line numberDiff line change
@@ -1708,6 +1708,7 @@ function process_component_options(component, nodes) {
17081708
case 'customElement': {
17091709
component_options.customElement =
17101710
component_options.customElement || /** @type {any} */ ({});
1711+
17111712
const { value } = attribute;
17121713
if (value[0].type === 'MustacheTag' && value[0].expression?.value === null) {
17131714
component_options.customElement.tag = null;
@@ -1718,12 +1719,14 @@ function process_component_options(component, nodes) {
17181719
} else if (value[0].expression.type !== 'ObjectExpression') {
17191720
return component.error(attribute, compiler_errors.invalid_customElement_attribute);
17201721
}
1722+
17211723
const tag = value[0].expression.properties.find((prop) => prop.key.name === 'tag');
17221724
if (tag) {
17231725
parse_tag(tag, tag.value?.value);
17241726
} else {
17251727
return component.error(attribute, compiler_errors.invalid_customElement_attribute);
17261728
}
1729+
17271730
const props = value[0].expression.properties.find((prop) => prop.key.name === 'props');
17281731
if (props) {
17291732
const error = () =>
@@ -1768,6 +1771,7 @@ function process_component_options(component, nodes) {
17681771
}
17691772
}
17701773
}
1774+
17711775
const shadow = value[0].expression.properties.find(
17721776
(prop) => prop.key.name === 'shadow'
17731777
);
@@ -1778,6 +1782,14 @@ function process_component_options(component, nodes) {
17781782
}
17791783
component_options.customElement.shadow = shadowdom;
17801784
}
1785+
1786+
const extend = value[0].expression.properties.find(
1787+
(prop) => prop.key.name === 'extend'
1788+
);
1789+
if (extend?.value) {
1790+
component_options.customElement.extend = extend.value;
1791+
}
1792+
17811793
break;
17821794
}
17831795
case 'namespace': {
@@ -1851,7 +1863,8 @@ function get_sourcemap_source_filename(compile_options) {
18511863
: get_basename(compile_options.filename);
18521864
}
18531865

1854-
/** @typedef {Object} ComponentOptions
1866+
/**
1867+
* @typedef {Object} ComponentOptions
18551868
* @property {string} [namespace]
18561869
* @property {boolean} [immutable]
18571870
* @property {boolean} [accessors]
@@ -1860,4 +1873,5 @@ function get_sourcemap_source_filename(compile_options) {
18601873
* @property {string|null} customElement.tag
18611874
* @property {'open'|'none'} [customElement.shadow]
18621875
* @property {Record<string,{attribute?:string;reflect?:boolean;type?:'String'|'Boolean'|'Number'|'Array'|'Object';}>} [customElement.props]
1876+
* @property {(ceClass: new () => HTMLElement) => new () => HTMLElement} [customElement.extend]
18631877
*/

packages/svelte/src/compiler/compile/render_dom/index.js

+9-10
Original file line numberDiff line numberDiff line change
@@ -588,20 +588,19 @@ export default function dom(component, options) {
588588
.join(',');
589589
const use_shadow_dom =
590590
component.component_options.customElement?.shadow !== 'none' ? 'true' : 'false';
591+
592+
const create_ce = x`@create_custom_element(${name}, ${JSON.stringify(
593+
props_str
594+
)}, [${slots_str}], [${accessors_str}], ${use_shadow_dom}, ${
595+
component.component_options.customElement?.extend
596+
})`;
597+
591598
if (component.component_options.customElement?.tag) {
592599
body.push(
593-
b`@_customElements.define("${
594-
component.component_options.customElement.tag
595-
}", @create_custom_element(${name}, ${JSON.stringify(
596-
props_str
597-
)}, [${slots_str}], [${accessors_str}], ${use_shadow_dom}));`
600+
b`@_customElements.define("${component.component_options.customElement.tag}", ${create_ce});`
598601
);
599602
} else {
600-
body.push(
601-
b`@create_custom_element(${name}, ${JSON.stringify(
602-
props_str
603-
)}, [${slots_str}], [${accessors_str}], ${use_shadow_dom});`
604-
);
603+
body.push(b`${create_ce}`);
605604
}
606605
}
607606

packages/svelte/src/runtime/internal/Component.js

+8-2
Original file line numberDiff line numberDiff line change
@@ -383,15 +383,17 @@ function get_custom_element_value(prop, value, props_definition, transform) {
383383
* @param {string[]} slots The slots to create
384384
* @param {string[]} accessors Other accessors besides the ones for props the component has
385385
* @param {boolean} use_shadow_dom Whether to use shadow DOM
386+
* @param {(ce: new () => HTMLElement) => new () => HTMLElement} [extend]
386387
*/
387388
export function create_custom_element(
388389
Component,
389390
props_definition,
390391
slots,
391392
accessors,
392-
use_shadow_dom
393+
use_shadow_dom,
394+
extend
393395
) {
394-
const Class = class extends SvelteElement {
396+
let Class = class extends SvelteElement {
395397
constructor() {
396398
super(Component, slots, use_shadow_dom);
397399
this.$$p_d = props_definition;
@@ -421,6 +423,10 @@ export function create_custom_element(
421423
}
422424
});
423425
});
426+
if (extend) {
427+
// @ts-expect-error - assigning here is fine
428+
Class = extend(Class);
429+
}
424430
Component.element = /** @type {any} */ (Class);
425431
return Class;
426432
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<svelte:options
2+
customElement={{
3+
tag: 'custom-element',
4+
extend: (CeClass) => {
5+
return class extends CeClass {
6+
updateFoo(value) {
7+
this.foo = value;
8+
}
9+
};
10+
}
11+
}}
12+
/>
13+
14+
<script>
15+
export function updateFoo(value) {
16+
foo = value;
17+
}
18+
19+
export let foo;
20+
</script>
21+
22+
<p>{foo}</p>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import * as assert from 'assert.js';
2+
import { tick } from 'svelte';
3+
import './main.svelte';
4+
5+
export default async function (target) {
6+
const element = document.createElement('custom-element');
7+
element.updateFoo('42');
8+
target.appendChild(element);
9+
await tick();
10+
11+
const el = target.querySelector('custom-element');
12+
const p = el.shadowRoot.querySelector('p');
13+
assert.equal(p.textContent, '42');
14+
}

0 commit comments

Comments
 (0)