diff --git a/.changeset/loose-sloths-guess.md b/.changeset/loose-sloths-guess.md new file mode 100644 index 000000000000..450040349d24 --- /dev/null +++ b/.changeset/loose-sloths-guess.md @@ -0,0 +1,5 @@ +--- +'svelte': minor +--- + +feat: allow passing `ShadowRootInit` object to custom element `shadow` option diff --git a/documentation/docs/07-misc/04-custom-elements.md b/documentation/docs/07-misc/04-custom-elements.md index 4e5afff7d251..4c2cb3aca3c3 100644 --- a/documentation/docs/07-misc/04-custom-elements.md +++ b/documentation/docs/07-misc/04-custom-elements.md @@ -66,7 +66,10 @@ The inner Svelte component is destroyed in the next tick after the `disconnected When constructing a custom element, you can tailor several aspects by defining `customElement` as an object within `` since Svelte 4. This object may contain the following properties: - `tag: string`: an optional `tag` property for the custom element's name. If set, a custom element with this tag name will be defined with the document's `customElements` registry upon importing this component. -- `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 +- `shadow`: an optional property to modify shadow root properties. It accepts the following values: + - `"none"`: No shadow root is created. Note that styles are then no longer encapsulated, and you can't use slots. + - `"open"`: Shadow root is created with the `mode: "open"` option. + - [`ShadowRootInit`](https://developer.mozilla.org/en-US/docs/Web/API/Element/attachShadow#options): You can pass a settings object that will be passed to `attachShadow()` when shadow root is created. - `props`: an optional property to modify certain details and behaviors of your component's properties. It 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: ""`. - `reflect: boolean`: By default, updated prop values do not reflect back to the DOM. To enable this behavior, set `reflect: true`. @@ -78,7 +81,11 @@ When constructing a custom element, you can tailor several aspects by defining ` "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: .. } } } +> "customElement" must be a string literal defining a valid custom element name or an object of the form { tag?: string; shadow?: "open" | "none" | `ShadowRootInit`; props?: { [key: string]: { attribute?: string; reflect?: boolean; type: .. } } } ## svelte_options_invalid_customelement_props @@ -419,7 +419,9 @@ HTML restricts where certain elements can appear. In case of a violation the bro ## svelte_options_invalid_customelement_shadow -> "shadow" must be either "open" or "none" +> "shadow" must be either "open", "none" or `ShadowRootInit` object. + +See https://developer.mozilla.org/en-US/docs/Web/API/Element/attachShadow#options for more information on valid shadow root constructor options ## svelte_options_invalid_tagname diff --git a/packages/svelte/src/compiler/errors.js b/packages/svelte/src/compiler/errors.js index d6c2efdfdc0d..9c678c52f66f 100644 --- a/packages/svelte/src/compiler/errors.js +++ b/packages/svelte/src/compiler/errors.js @@ -1550,12 +1550,12 @@ export function svelte_options_invalid_attribute_value(node, list) { } /** - * "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: .. } } } + * "customElement" must be a string literal defining a valid custom element name or an object of the form { tag?: string; shadow?: "open" | "none" | `ShadowRootInit`; props?: { [key: string]: { attribute?: string; reflect?: boolean; type: .. } } } * @param {null | number | NodeLike} node * @returns {never} */ export function svelte_options_invalid_customelement(node) { - e(node, 'svelte_options_invalid_customelement', `"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: .. } } }\nhttps://svelte.dev/e/svelte_options_invalid_customelement`); + e(node, 'svelte_options_invalid_customelement', `"customElement" must be a string literal defining a valid custom element name or an object of the form { tag?: string; shadow?: "open" | "none" | \`ShadowRootInit\`; props?: { [key: string]: { attribute?: string; reflect?: boolean; type: .. } } }\nhttps://svelte.dev/e/svelte_options_invalid_customelement`); } /** @@ -1568,12 +1568,12 @@ export function svelte_options_invalid_customelement_props(node) { } /** - * "shadow" must be either "open" or "none" + * "shadow" must be either "open", "none" or `ShadowRootInit` object. * @param {null | number | NodeLike} node * @returns {never} */ export function svelte_options_invalid_customelement_shadow(node) { - e(node, 'svelte_options_invalid_customelement_shadow', `"shadow" must be either "open" or "none"\nhttps://svelte.dev/e/svelte_options_invalid_customelement_shadow`); + e(node, 'svelte_options_invalid_customelement_shadow', `"shadow" must be either "open", "none" or \`ShadowRootInit\` object.\nhttps://svelte.dev/e/svelte_options_invalid_customelement_shadow`); } /** diff --git a/packages/svelte/src/compiler/phases/1-parse/read/options.js b/packages/svelte/src/compiler/phases/1-parse/read/options.js index a36e10146809..2677fb3b6115 100644 --- a/packages/svelte/src/compiler/phases/1-parse/read/options.js +++ b/packages/svelte/src/compiler/phases/1-parse/read/options.js @@ -133,11 +133,13 @@ export default function read_options(node) { const shadow = properties.find(([name]) => name === 'shadow')?.[1]; if (shadow) { - const shadowdom = shadow?.value; - if (shadowdom !== 'open' && shadowdom !== 'none') { - e.svelte_options_invalid_customelement_shadow(shadow); + if (shadow.type === 'Literal' && (shadow.value === 'open' || shadow.value === 'none')) { + ce.shadow = shadow.value; + } else if (shadow.type === 'ObjectExpression') { + ce.shadow = shadow; + } else { + e.svelte_options_invalid_customelement_shadow(attribute); } - ce.shadow = shadowdom; } const extend = properties.find(([name]) => name === 'extend')?.[1]; diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js index f51042eb7c62..c9d33affeacc 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js @@ -643,7 +643,16 @@ export function client_component(analysis, options) { const accessors_str = b.array( analysis.exports.map(({ name, alias }) => b.literal(alias ?? name)) ); - const use_shadow_dom = typeof ce === 'boolean' || ce.shadow !== 'none' ? true : false; + + /** @type {ESTree.ObjectExpression | undefined} */ + let shadow_root_init; + if (typeof ce === 'boolean' || ce.shadow === 'open' || ce.shadow === undefined) { + shadow_root_init = b.object([b.init('mode', b.literal('open'))]); + } else if (ce.shadow === 'none') { + shadow_root_init = undefined; + } else { + shadow_root_init = ce.shadow; + } const create_ce = b.call( '$.create_custom_element', @@ -651,7 +660,7 @@ export function client_component(analysis, options) { b.object(props_str), slots_str, accessors_str, - b.literal(use_shadow_dom), + shadow_root_init, /** @type {any} */ (typeof ce !== 'boolean' ? ce.extend : undefined) ); diff --git a/packages/svelte/src/compiler/types/template.d.ts b/packages/svelte/src/compiler/types/template.d.ts index b529a2dda9f6..b924406d0f97 100644 --- a/packages/svelte/src/compiler/types/template.d.ts +++ b/packages/svelte/src/compiler/types/template.d.ts @@ -87,7 +87,7 @@ export namespace AST { css?: 'injected'; customElement?: { tag?: string; - shadow?: 'open' | 'none'; + shadow?: 'open' | 'none' | ObjectExpression | undefined; props?: Record< string, { diff --git a/packages/svelte/src/internal/client/dom/elements/custom-element.js b/packages/svelte/src/internal/client/dom/elements/custom-element.js index 2d118bfab3a4..40d3d68ec0a8 100644 --- a/packages/svelte/src/internal/client/dom/elements/custom-element.js +++ b/packages/svelte/src/internal/client/dom/elements/custom-element.js @@ -35,18 +35,23 @@ if (typeof HTMLElement === 'function') { $$l_u = new Map(); /** @type {any} The managed render effect for reflecting attributes */ $$me; + /** @type {ShadowRoot | null} The ShadowRoot of the custom element */ + $$shadowRoot = null; /** * @param {*} $$componentCtor * @param {*} $$slots - * @param {*} use_shadow_dom + * @param {ShadowRootInit | undefined} shadow_root_init */ - constructor($$componentCtor, $$slots, use_shadow_dom) { + constructor($$componentCtor, $$slots, shadow_root_init) { super(); this.$$ctor = $$componentCtor; this.$$s = $$slots; - if (use_shadow_dom) { - this.attachShadow({ mode: 'open' }); + + if (shadow_root_init) { + // We need to store the reference to shadow root, because `closed` shadow root cannot be + // accessed with `this.shadowRoot`. + this.$$shadowRoot = this.attachShadow(shadow_root_init); } } @@ -136,7 +141,7 @@ if (typeof HTMLElement === 'function') { } this.$$c = createClassComponent({ component: this.$$ctor, - target: this.shadowRoot || this, + target: this.$$shadowRoot || this, props: { ...this.$$d, $$slots, @@ -277,7 +282,7 @@ function get_custom_elements_slots(element) { * @param {Record} props_definition The props to observe * @param {string[]} slots The slots to create * @param {string[]} exports Explicitly exported values, other than props - * @param {boolean} use_shadow_dom Whether to use shadow DOM + * @param {ShadowRootInit | undefined} shadow_root_init Options passed to shadow DOM constructor * @param {(ce: new () => HTMLElement) => new () => HTMLElement} [extend] */ export function create_custom_element( @@ -285,12 +290,12 @@ export function create_custom_element( props_definition, slots, exports, - use_shadow_dom, + shadow_root_init, extend ) { let Class = class extends SvelteElement { constructor() { - super(Component, slots, use_shadow_dom); + super(Component, slots, shadow_root_init); this.$$p_d = props_definition; } static get observedAttributes() { diff --git a/packages/svelte/tests/runtime-browser/custom-elements-samples/closed-shadow-dom/_config.js b/packages/svelte/tests/runtime-browser/custom-elements-samples/closed-shadow-dom/_config.js new file mode 100644 index 000000000000..9e09967dab0c --- /dev/null +++ b/packages/svelte/tests/runtime-browser/custom-elements-samples/closed-shadow-dom/_config.js @@ -0,0 +1,13 @@ +import { test } from '../../assert'; +const tick = () => Promise.resolve(); + +export default test({ + async test({ assert, target }) { + target.innerHTML = ''; + await tick(); + + const el = target.querySelector('custom-element'); + + assert.equal(el.shadowRoot, null); + } +}); diff --git a/packages/svelte/tests/runtime-browser/custom-elements-samples/closed-shadow-dom/main.svelte b/packages/svelte/tests/runtime-browser/custom-elements-samples/closed-shadow-dom/main.svelte new file mode 100644 index 000000000000..93744481b38d --- /dev/null +++ b/packages/svelte/tests/runtime-browser/custom-elements-samples/closed-shadow-dom/main.svelte @@ -0,0 +1,3 @@ + + +

Hello world!

diff --git a/packages/svelte/tests/runtime-browser/custom-elements-samples/shadow-root-init-options/_config.js b/packages/svelte/tests/runtime-browser/custom-elements-samples/shadow-root-init-options/_config.js new file mode 100644 index 000000000000..106d27929ee4 --- /dev/null +++ b/packages/svelte/tests/runtime-browser/custom-elements-samples/shadow-root-init-options/_config.js @@ -0,0 +1,18 @@ +import { test } from '../../assert'; +const tick = () => Promise.resolve(); + +export default test({ + async test({ assert, target }) { + target.innerHTML = ''; + await tick(); + + /** @type {ShadowRoot} */ + const shadowRoot = target.querySelector('custom-element').shadowRoot; + + assert.equal(shadowRoot.mode, 'open'); + assert.equal(shadowRoot.clonable, true); + assert.equal(shadowRoot.delegatesFocus, true); + assert.equal(shadowRoot.serializable, true); + assert.equal(shadowRoot.slotAssignment, 'manual'); + } +}); diff --git a/packages/svelte/tests/runtime-browser/custom-elements-samples/shadow-root-init-options/main.svelte b/packages/svelte/tests/runtime-browser/custom-elements-samples/shadow-root-init-options/main.svelte new file mode 100644 index 000000000000..25d69d7ef9ee --- /dev/null +++ b/packages/svelte/tests/runtime-browser/custom-elements-samples/shadow-root-init-options/main.svelte @@ -0,0 +1,14 @@ + + +

Hello world!

diff --git a/packages/svelte/tests/validator/samples/tag-non-string/errors.json b/packages/svelte/tests/validator/samples/tag-non-string/errors.json index 71f8df4d0092..5473e18b7d37 100644 --- a/packages/svelte/tests/validator/samples/tag-non-string/errors.json +++ b/packages/svelte/tests/validator/samples/tag-non-string/errors.json @@ -1,7 +1,7 @@ [ { "code": "svelte_options_invalid_customelement", - "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: .. } } }", + "message": "\"customElement\" must be a string literal defining a valid custom element name or an object of the form { tag?: string; shadow?: \"open\" | \"none\" | `ShadowRootInit`; props?: { [key: string]: { attribute?: string; reflect?: boolean; type: .. } } }", "start": { "line": 1, "column": 16 diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 9ace341e1609..5b97fd86a2eb 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -1228,7 +1228,7 @@ declare module 'svelte/compiler' { css?: 'injected'; customElement?: { tag?: string; - shadow?: 'open' | 'none'; + shadow?: 'open' | 'none' | ObjectExpression | undefined; props?: Record< string, {