diff --git a/elements/index.d.ts b/elements/index.d.ts index 7595d767bf..fcdeacecf8 100644 --- a/elements/index.d.ts +++ b/elements/index.d.ts @@ -1589,7 +1589,11 @@ export interface SvelteHTMLElements { 'svelte:document': HTMLAttributes; 'svelte:body': HTMLAttributes; 'svelte:fragment': { slot?: string }; - 'svelte:options': { [name: string]: any }; + 'svelte:options': { + tag?: string | null | undefined; + cePropsDefinition?: Record | undefined, + [name: string]: any + }; 'svelte:head': { [name: string]: any }; [name: string]: { [name: string]: any }; diff --git a/src/compiler/compile/Component.ts b/src/compiler/compile/Component.ts index c90272620c..9247436e64 100644 --- a/src/compiler/compile/Component.ts +++ b/src/compiler/compile/Component.ts @@ -25,7 +25,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'; @@ -45,6 +45,7 @@ interface ComponentOptions { immutable?: boolean; accessors?: boolean; preserveWhitespace?: boolean; + cePropsDefinition?: Record; } const regex_leading_directory_separator = /^[/\\]/; @@ -1524,7 +1525,7 @@ function process_component_options(component: Component, nodes) { ? component.compile_options.accessors : !!component.compile_options.customElement, preserveWhitespace: !!component.compile_options.preserveWhitespace, - namespace: component.compile_options.namespace + namespace: component.compile_options.namespace, }; const node = nodes.find(node => node.name === 'svelte:options'); @@ -1573,6 +1574,44 @@ function process_component_options(component: Component, nodes) { break; } + case 'cePropsDefinition': { + const error = () => component.error(attribute, compiler_errors.invalid_cePropsDefinition_attribute); + const { value } = attribute; + const chunk = value[0]; + component_options.cePropsDefinition = {}; + + if (!chunk) { + break; + }; + + if (value.length > 1 || chunk.expression?.type !== 'ObjectExpression') { + return error(); + } + + 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') { + return error(); + } + component_options.cePropsDefinition[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.cePropsDefinition[property.key.name][prop.key.name] = prop.value.value; + } + } + + break; + } + case 'namespace': { const ns = get_value(attribute, compiler_errors.invalid_namespace_attribute); diff --git a/src/compiler/compile/compiler_errors.ts b/src/compiler/compile/compiler_errors.ts index bad3911673..fb9130f640 100644 --- a/src/compiler/compile/compiler_errors.ts +++ b/src/compiler/compile/compiler_errors.ts @@ -206,6 +206,11 @@ export default { code: 'invalid-tag-attribute', message: "'tag' must be a string literal" }, + invalid_cePropsDefinition_attribute: { + code: 'invalid-cePropsDefinition-attribute', + message: "'cePropsDefinition' must be a statically analyzable object literal of the form " + + "'{ prop: { attribute?: string; type?: 'String' | 'Boolean' | 'Number' | 'Array' | 'Object', reflect?: boolean; } }'" + }, invalid_namespace_property: (namespace: string, suggestion?: string) => ({ code: 'invalid-namespace-property', message: `Invalid namespace '${namespace}'` + (suggestion ? ` (did you mean '${suggestion}'?)` : '') diff --git a/src/compiler/compile/render_dom/index.ts b/src/compiler/compile/render_dom/index.ts index 200dbdb2ba..b05feed749 100644 --- a/src/compiler/compile/render_dom/index.ts +++ b/src/compiler/compile/render_dom/index.ts @@ -12,7 +12,6 @@ import { RawSourceMap, DecodedSourceMap } from '@ampproject/remapping/dist/types 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, @@ -549,41 +548,20 @@ export default function dom( 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)`} - - @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); - } - - ${(props.length > 0 || uses_props || uses_rest) && b` - if (options.props) { - this.$set(options.props); - @flush(); - }`} - } - } + const props_str = writable_props.reduce((def, prop) => { + def[prop.export_name] = component.component_options.cePropsDefinition?.[prop.export_name] || {}; + if (prop.is_boolean && !def[prop.export_name].type) { + def[prop.export_name].type = 'Boolean'; } - `[0] as ClassDeclaration; - - const props_str = JSON.stringify(writable_props.map(prop => prop.is_boolean ? { name: prop.export_name, type: 'boolean' } : prop.export_name)); + 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(','); body.push( - b`@_customElements.define("${component.tag}", @create_custom_element(${name}, ${props_str}, [${slots_str}], [${accessors_str}]));` + b`@_customElements.define("${component.tag}", @create_custom_element(${name}, ${JSON.stringify(props_str)}, [${slots_str}], [${accessors_str}]));` ); } diff --git a/src/runtime/internal/Component.ts b/src/runtime/internal/Component.ts index b370bfbff0..c2225f6267 100644 --- a/src/runtime/internal/Component.ts +++ b/src/runtime/internal/Component.ts @@ -151,7 +151,7 @@ if (typeof HTMLElement === 'function') { private $$connected = false; private $$data = {}; private $$reflecting = false; - private $$boolean_props: string[] = []; + private $$props_definition: Record = {}; constructor( private $$componentCtor: ComponentType, @@ -205,13 +205,12 @@ if (typeof HTMLElement === 'function') { for (const attribute of this.attributes) { // this.$$data takes precedence over this.attributes - if (!(attribute.name in this.$$data)) { - this.$$data[attribute.name] = get_custom_element_value(attribute.name, attribute.value, this.$$boolean_props); + 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'); } } - // Dilemma: We need to set the component props eagerly or they have the wrong value for actions/onMount etc. - // Boolean attributes are represented by the empty string, and we don't know if they represent boolean or string props. this.$$component = new this.$$componentCtor({ target: this.shadowRoot!, props: { @@ -225,12 +224,13 @@ if (typeof HTMLElement === 'function') { } } - // TODO 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 probably helpful + // 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; - this.$$data[attr] = get_custom_element_value(attr, newValue, this.$$boolean_props); + attr = this.$$get_prop_name(attr); + this.$$data[attr] = get_custom_element_value(attr, newValue, this.$$props_definition, 'toProp'); this.$$component![attr] = this.$$data[attr]; } @@ -244,28 +244,54 @@ if (typeof HTMLElement === 'function') { } }); } + + private $$get_prop_name(attribute_name: string): string { + return Object.keys(this.$$props_definition).find(key => this.$$props_definition[key].attribute === attribute_name) || attribute_name; + } }; } -/** - * Attribute value types that should be reflected to the DOM. Helpful - * for people relying on the custom element's attributes to be present, - * for example when using a CSS selector which relies on an attribute. - */ -const should_reflect = ['string', 'number', 'boolean']; - -function camelToHyphen(str: string) { - return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); +function get_custom_element_value(prop, value, props_definition: Record, transform?: 'toAttribute' | 'toProp') { + value = props_definition[prop]?.type === 'Boolean' && typeof value !== 'boolean' ? value != null : value; + if (!transform || !props_definition[prop]) { + return value; + } else if (transform === 'toAttribute') { + switch (props_definition[prop].type) { + case 'Object': + case 'Array': + return JSON.stringify(value); + case 'Boolean': + return value ? '' : null; + case 'Number': + return value == null ? null : value; + default: + return value; + } + } else { + switch (props_definition[prop].type) { + case 'Object': + case 'Array': + return JSON.parse(value); + case 'Boolean': + return value !== null; + case 'Number': + return value == null ? null : +value; + default: + return value; + } + } } -function get_custom_element_value(prop, value, boolean_attrs) { - return value === '' && boolean_attrs.indexOf(prop) !== -1 ? true : value; +interface CustomElementPropDefinition { + reflect?: boolean; + type?: 'String' | 'Boolean' | 'Number' | 'Array' | 'Object'; + attribute?: string; } /** * Turn a Svelte component into a custom element. * @param Component A Svelte component constructor - * @param props The props to observe + * @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 styles Additional styles to apply to the shadow root (not needed for Svelte components compiled with `customElement: true`) @@ -273,18 +299,15 @@ function get_custom_element_value(prop, value, boolean_attrs) { */ export function create_custom_element( Component: ComponentType, - props: (string | { name: string; type: 'boolean' })[], + props_definition: Record, slots: string[], accessors: string[], styles?: string, ) { - const prop_names = props.map((prop) => (typeof prop === 'string' ? prop : prop.name)); - const boolean_props = props.filter((prop) => typeof prop !== 'string').map((prop) => (prop as { name:string }).name); - const Class = class extends SvelteElement { constructor() { super(Component, slots); - this.$$boolean_props = boolean_props; + this.$$props_definition = props_definition; if (styles) { const style = document.createElement('style'); style.textContent = styles; @@ -293,7 +316,7 @@ export function create_custom_element( } static get observedAttributes() { - return prop_names; + return Object.keys(props_definition).map(key => props_definition[key].attribute || key); } }; @@ -306,37 +329,36 @@ export function create_custom_element( }, set(value) { - this.$$data[prop] = get_custom_element_value(prop, value, boolean_props); + value = get_custom_element_value(prop, value, props_definition); + this.$$data[prop] = value; if (this.$$component) { this.$$component[prop] = value; } - if(should_reflect.indexOf(typeof value) !== -1 || value == null) { + if(props_definition[prop].reflect) { this.$$reflecting = true; if (value === false || value == null) { this.removeAttribute(prop); } else { - this.setAttribute(prop, value); + this.setAttribute( + props_definition[prop].attribute || prop, + get_custom_element_value(prop, value, props_definition, 'toAttribute') as string + ); } this.$$reflecting = false; } } - }) + }); } - prop_names.forEach((prop) => { + Object.keys(props_definition).forEach((prop) => { createProperty(prop, prop); // will be ce.camcelcase = "foo" const lower = prop.toLowerCase(); if (lower !== prop) { createProperty(lower, prop); } - // also support hyphenated version where will be ce['camel-case'] = "foo" - const hyphen = camelToHyphen(prop); - if (hyphen !== lower) { - createProperty(hyphen, prop) - } }); accessors.forEach(accessor => { diff --git a/test/custom-elements/samples/camel-case-attribute/main.svelte b/test/custom-elements/samples/camel-case-attribute/main.svelte new file mode 100644 index 0000000000..392af5f857 --- /dev/null +++ b/test/custom-elements/samples/camel-case-attribute/main.svelte @@ -0,0 +1,17 @@ + + + + +

Hello {camelCase}!

+{#each anArray as item} +

{item}

+{/each} diff --git a/test/custom-elements/samples/camel-case-attribute/test.js b/test/custom-elements/samples/camel-case-attribute/test.js new file mode 100644 index 0000000000..6747a77d13 --- /dev/null +++ b/test/custom-elements/samples/camel-case-attribute/test.js @@ -0,0 +1,24 @@ +import * as assert from 'assert'; +import './main.svelte'; + +export default function (target) { + target.innerHTML = ''; + const el = target.querySelector('custom-element'); + + assert.equal(el.shadowRoot.innerHTML, '

Hello world!

1

2

'); + + el.setAttribute('camel-case', 'universe'); + el.setAttribute('an-array', '[3,4]'); + assert.equal(el.shadowRoot.innerHTML, '

Hello universe!

3

4

'); + assert.equal(target.innerHTML, '') + + el.camelCase = 'galaxy'; + el.anArray = [5, 6]; + assert.equal(el.shadowRoot.innerHTML, '

Hello galaxy!

5

6

'); + assert.equal(target.innerHTML, '') + + el.camelcase = 'solar system'; + el.anarray = [7, 8]; + assert.equal(el.shadowRoot.innerHTML, '

Hello solar system!

7

8

'); + assert.equal(target.innerHTML, '') +} diff --git a/test/custom-elements/samples/ce-options-valid/main.svelte b/test/custom-elements/samples/ce-options-valid/main.svelte new file mode 100644 index 0000000000..9dfd2b8f3a --- /dev/null +++ b/test/custom-elements/samples/ce-options-valid/main.svelte @@ -0,0 +1,12 @@ + + + + +

Hello {name}!

diff --git a/test/custom-elements/samples/ce-options-valid/test.js b/test/custom-elements/samples/ce-options-valid/test.js new file mode 100644 index 0000000000..75de66d5c6 --- /dev/null +++ b/test/custom-elements/samples/ce-options-valid/test.js @@ -0,0 +1,11 @@ +import * as assert from 'assert'; +import './main.svelte'; + +export default function (target) { + target.innerHTML = ''; + + const el = target.querySelector('custom-element'); + const h1 = el.shadowRoot.querySelector('h1'); + + assert.equal(h1.textContent, 'Hello world!'); +} diff --git a/test/custom-elements/samples/no-svelte-options/test.js b/test/custom-elements/samples/no-svelte-options/test.js index a3c2a48574..ebb7db4d51 100644 --- a/test/custom-elements/samples/no-svelte-options/test.js +++ b/test/custom-elements/samples/no-svelte-options/test.js @@ -3,7 +3,7 @@ import CustomElement from './main.svelte'; import { create_custom_element } from 'svelte/internal'; export default function (target) { - customElements.define('no-tag', create_custom_element(CustomElement, ['name'], [], [])); + customElements.define('no-tag', create_custom_element(CustomElement, {name: {}}, [], [])); target.innerHTML = ''; const el = target.querySelector('no-tag'); diff --git a/test/custom-elements/samples/no-tag-warning/test.js b/test/custom-elements/samples/no-tag-warning/test.js index a3c2a48574..170fbb4f9d 100644 --- a/test/custom-elements/samples/no-tag-warning/test.js +++ b/test/custom-elements/samples/no-tag-warning/test.js @@ -3,7 +3,7 @@ import CustomElement from './main.svelte'; import { create_custom_element } from 'svelte/internal'; export default function (target) { - customElements.define('no-tag', create_custom_element(CustomElement, ['name'], [], [])); + customElements.define('no-tag', create_custom_element(CustomElement, { name: {}}, [], [])); target.innerHTML = ''; const el = target.querySelector('no-tag'); diff --git a/test/custom-elements/samples/no-tag/test.js b/test/custom-elements/samples/no-tag/test.js index a3c2a48574..ddf193bc1c 100644 --- a/test/custom-elements/samples/no-tag/test.js +++ b/test/custom-elements/samples/no-tag/test.js @@ -3,7 +3,7 @@ import CustomElement from './main.svelte'; import { create_custom_element } from 'svelte/internal'; export default function (target) { - customElements.define('no-tag', create_custom_element(CustomElement, ['name'], [], [])); + customElements.define('no-tag', create_custom_element(CustomElement, { name: {} }, [], [])); target.innerHTML = ''; const el = target.querySelector('no-tag'); diff --git a/test/custom-elements/samples/reflect-attributes/main.svelte b/test/custom-elements/samples/reflect-attributes/main.svelte index 726b0838c9..59fadcc3c6 100644 --- a/test/custom-elements/samples/reflect-attributes/main.svelte +++ b/test/custom-elements/samples/reflect-attributes/main.svelte @@ -1,8 +1,11 @@ - + diff --git a/test/custom-elements/samples/reflect-attributes/my-widget.svelte b/test/custom-elements/samples/reflect-attributes/my-widget.svelte index 02685c8cb3..8c9a2c3de3 100644 --- a/test/custom-elements/samples/reflect-attributes/my-widget.svelte +++ b/test/custom-elements/samples/reflect-attributes/my-widget.svelte @@ -1,4 +1,7 @@ - +