introduce svelte:option cePropsDefinition

pull/8457/head
Simon Holthausen 3 years ago
parent 1131584add
commit 82de3f625d

@ -1589,7 +1589,11 @@ export interface SvelteHTMLElements {
'svelte:document': HTMLAttributes<Document>; 'svelte:document': HTMLAttributes<Document>;
'svelte:body': HTMLAttributes<HTMLElement>; 'svelte:body': HTMLAttributes<HTMLElement>;
'svelte:fragment': { slot?: string }; 'svelte:fragment': { slot?: string };
'svelte:options': { [name: string]: any }; 'svelte:options': {
tag?: string | null | undefined;
cePropsDefinition?: Record<string, { attribute?: string; reflect?: boolean; type?: 'String' | 'Boolean' | 'Number' | 'Array' | 'Object' }> | undefined,
[name: string]: any
};
'svelte:head': { [name: string]: any }; 'svelte:head': { [name: string]: any };
[name: string]: { [name: string]: any }; [name: string]: { [name: string]: any };

@ -25,7 +25,7 @@ import TemplateScope from './nodes/shared/TemplateScope';
import fuzzymatch from '../utils/fuzzymatch'; import fuzzymatch from '../utils/fuzzymatch';
import get_object from './utils/get_object'; import get_object from './utils/get_object';
import Slot from './nodes/Slot'; 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 add_to_set from './utils/add_to_set';
import check_graph_for_cycles from './utils/check_graph_for_cycles'; import check_graph_for_cycles from './utils/check_graph_for_cycles';
import { print, b } from 'code-red'; import { print, b } from 'code-red';
@ -45,6 +45,7 @@ interface ComponentOptions {
immutable?: boolean; immutable?: boolean;
accessors?: boolean; accessors?: boolean;
preserveWhitespace?: boolean; preserveWhitespace?: boolean;
cePropsDefinition?: Record<string, { reflect?: boolean; type?: 'String' | 'Boolean' | 'Number' | 'Array' | 'Object', attribute?: string }>;
} }
const regex_leading_directory_separator = /^[/\\]/; const regex_leading_directory_separator = /^[/\\]/;
@ -1524,7 +1525,7 @@ function process_component_options(component: Component, nodes) {
? component.compile_options.accessors ? component.compile_options.accessors
: !!component.compile_options.customElement, : !!component.compile_options.customElement,
preserveWhitespace: !!component.compile_options.preserveWhitespace, preserveWhitespace: !!component.compile_options.preserveWhitespace,
namespace: component.compile_options.namespace namespace: component.compile_options.namespace,
}; };
const node = nodes.find(node => node.name === 'svelte:options'); const node = nodes.find(node => node.name === 'svelte:options');
@ -1573,6 +1574,44 @@ function process_component_options(component: Component, nodes) {
break; 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': { case 'namespace': {
const ns = get_value(attribute, compiler_errors.invalid_namespace_attribute); const ns = get_value(attribute, compiler_errors.invalid_namespace_attribute);

@ -206,6 +206,11 @@ export default {
code: 'invalid-tag-attribute', code: 'invalid-tag-attribute',
message: "'tag' must be a string literal" 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) => ({ invalid_namespace_property: (namespace: string, suggestion?: string) => ({
code: 'invalid-namespace-property', code: 'invalid-namespace-property',
message: `Invalid namespace '${namespace}'` + (suggestion ? ` (did you mean '${suggestion}'?)` : '') message: `Invalid namespace '${namespace}'` + (suggestion ? ` (did you mean '${suggestion}'?)` : '')

@ -12,7 +12,6 @@ import { RawSourceMap, DecodedSourceMap } from '@ampproject/remapping/dist/types
import { flatten } from '../../utils/flatten'; import { flatten } from '../../utils/flatten';
import check_enable_sourcemap from '../utils/check_enable_sourcemap'; import check_enable_sourcemap from '../utils/check_enable_sourcemap';
import { push_array } from '../../utils/push_array'; import { push_array } from '../../utils/push_array';
import { regex_backslashes } from '../../utils/patterns';
export default function dom( export default function dom(
component: Component, component: Component,
@ -549,41 +548,20 @@ export default function dom(
init_props = x`{ ...${init_props}, $$slots: @get_custom_elements_slots(this) }`; init_props = x`{ ...${init_props}, $$slots: @get_custom_elements_slots(this) }`;
} }
const declaration = b` const props_str = writable_props.reduce((def, prop) => {
class ${name} extends @SvelteElement { def[prop.export_name] = component.component_options.cePropsDefinition?.[prop.export_name] || {};
constructor(options) { if (prop.is_boolean && !def[prop.export_name].type) {
super(); def[prop.export_name].type = 'Boolean';
${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();
}`}
}
}
} }
`[0] as ClassDeclaration; return def;
}, {});
const props_str = JSON.stringify(writable_props.map(prop => prop.is_boolean ? { name: prop.export_name, type: 'boolean' } : prop.export_name));
const slots_str = [...component.slots.keys()].map(key => `"${key}"`).join(','); const slots_str = [...component.slots.keys()].map(key => `"${key}"`).join(',');
const accessors_str = accessors const accessors_str = accessors
.filter(accessor => !writable_props.some(prop => prop.export_name === accessor.key.name)) .filter(accessor => !writable_props.some(prop => prop.export_name === accessor.key.name))
.map(accessor => `"${accessor.key.name}"`) .map(accessor => `"${accessor.key.name}"`)
.join(','); .join(',');
body.push( 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}]));`
); );
} }

@ -151,7 +151,7 @@ if (typeof HTMLElement === 'function') {
private $$connected = false; private $$connected = false;
private $$data = {}; private $$data = {};
private $$reflecting = false; private $$reflecting = false;
private $$boolean_props: string[] = []; private $$props_definition: Record<string, CustomElementPropDefinition> = {};
constructor( constructor(
private $$componentCtor: ComponentType, private $$componentCtor: ComponentType,
@ -205,13 +205,12 @@ if (typeof HTMLElement === 'function') {
for (const attribute of this.attributes) { for (const attribute of this.attributes) {
// this.$$data takes precedence over this.attributes // this.$$data takes precedence over this.attributes
if (!(attribute.name in this.$$data)) { const name = this.$$get_prop_name(attribute.name);
this.$$data[attribute.name] = get_custom_element_value(attribute.name, attribute.value, this.$$boolean_props); 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({ this.$$component = new this.$$componentCtor({
target: this.shadowRoot!, target: this.shadowRoot!,
props: { 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 // 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 // and setting attributes through setAttribute etc, this is helpful
attributeChangedCallback(attr: string, _oldValue: any, newValue: any) { attributeChangedCallback(attr: string, _oldValue: any, newValue: any) {
if (this.$$reflecting) return; 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]; 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;
}
}; };
} }
/** function get_custom_element_value(prop, value, props_definition: Record<string, CustomElementPropDefinition>, transform?: 'toAttribute' | 'toProp') {
* Attribute value types that should be reflected to the DOM. Helpful value = props_definition[prop]?.type === 'Boolean' && typeof value !== 'boolean' ? value != null : value;
* for people relying on the custom element's attributes to be present, if (!transform || !props_definition[prop]) {
* for example when using a CSS selector which relies on an attribute. return value;
*/ } else if (transform === 'toAttribute') {
const should_reflect = ['string', 'number', 'boolean']; switch (props_definition[prop].type) {
case 'Object':
function camelToHyphen(str: string) { case 'Array':
return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); 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) { interface CustomElementPropDefinition {
return value === '' && boolean_attrs.indexOf(prop) !== -1 ? true : value; reflect?: boolean;
type?: 'String' | 'Boolean' | 'Number' | 'Array' | 'Object';
attribute?: string;
} }
/** /**
* Turn a Svelte component into a custom element. * Turn a Svelte component into a custom element.
* @param Component A Svelte component constructor * @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 slots The slots to create
* @param accessors Other accessors besides the ones for props the component has * @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`) * @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( export function create_custom_element(
Component: ComponentType, Component: ComponentType,
props: (string | { name: string; type: 'boolean' })[], props_definition: Record<string, CustomElementPropDefinition>,
slots: string[], slots: string[],
accessors: string[], accessors: string[],
styles?: 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 { const Class = class extends SvelteElement {
constructor() { constructor() {
super(Component, slots); super(Component, slots);
this.$$boolean_props = boolean_props; this.$$props_definition = props_definition;
if (styles) { if (styles) {
const style = document.createElement('style'); const style = document.createElement('style');
style.textContent = styles; style.textContent = styles;
@ -293,7 +316,7 @@ export function create_custom_element(
} }
static get observedAttributes() { 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) { 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) { if (this.$$component) {
this.$$component[prop] = value; this.$$component[prop] = value;
} }
if(should_reflect.indexOf(typeof value) !== -1 || value == null) { if(props_definition[prop].reflect) {
this.$$reflecting = true; this.$$reflecting = true;
if (value === false || value == null) { if (value === false || value == null) {
this.removeAttribute(prop); this.removeAttribute(prop);
} else { } 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; this.$$reflecting = false;
} }
} }
}) });
} }
prop_names.forEach((prop) => { Object.keys(props_definition).forEach((prop) => {
createProperty(prop, prop); createProperty(prop, prop);
// <c-e camelCase="foo" /> will be ce.camcelcase = "foo" // <c-e camelCase="foo" /> will be ce.camcelcase = "foo"
const lower = prop.toLowerCase(); const lower = prop.toLowerCase();
if (lower !== prop) { if (lower !== prop) {
createProperty(lower, prop); createProperty(lower, prop);
} }
// also support hyphenated version where <c-e camel-case="foo" /> will be ce['camel-case'] = "foo"
const hyphen = camelToHyphen(prop);
if (hyphen !== lower) {
createProperty(hyphen, prop)
}
}); });
accessors.forEach(accessor => { accessors.forEach(accessor => {

@ -0,0 +1,17 @@
<svelte:options
tag="custom-element"
cePropsDefinition={{
camelCase: { attribute: "camel-case" },
anArray: { attribute: "an-array", type: "Array", reflect: true },
}}
/>
<script>
export let camelCase;
export let anArray;
</script>
<h1>Hello {camelCase}!</h1>
{#each anArray as item}
<p>{item}</p>
{/each}

@ -0,0 +1,24 @@
import * as assert from 'assert';
import './main.svelte';
export default function (target) {
target.innerHTML = '<custom-element camel-case="world" an-array="[1,2]"></custom-element>';
const el = target.querySelector('custom-element');
assert.equal(el.shadowRoot.innerHTML, '<h1>Hello world!</h1> <p>1</p><p>2</p>');
el.setAttribute('camel-case', 'universe');
el.setAttribute('an-array', '[3,4]');
assert.equal(el.shadowRoot.innerHTML, '<h1>Hello universe!</h1> <p>3</p><p>4</p>');
assert.equal(target.innerHTML, '<custom-element camel-case="universe" an-array="[3,4]"></custom-element>')
el.camelCase = 'galaxy';
el.anArray = [5, 6];
assert.equal(el.shadowRoot.innerHTML, '<h1>Hello galaxy!</h1> <p>5</p><p>6</p>');
assert.equal(target.innerHTML, '<custom-element camel-case="universe" an-array="[5,6]"></custom-element>')
el.camelcase = 'solar system';
el.anarray = [7, 8];
assert.equal(el.shadowRoot.innerHTML, '<h1>Hello solar system!</h1> <p>7</p><p>8</p>');
assert.equal(target.innerHTML, '<custom-element camel-case="universe" an-array="[7,8]"></custom-element>')
}

@ -0,0 +1,12 @@
<svelte:options
tag="custom-element"
cePropsDefinition={{
name: { reflect: false, type: "String", attribute: "name" },
}}
/>
<script>
export let name;
</script>
<h1>Hello {name}!</h1>

@ -0,0 +1,11 @@
import * as assert from 'assert';
import './main.svelte';
export default function (target) {
target.innerHTML = '<custom-element name="world"></custom-element>';
const el = target.querySelector('custom-element');
const h1 = el.shadowRoot.querySelector('h1');
assert.equal(h1.textContent, 'Hello world!');
}

@ -3,7 +3,7 @@ import CustomElement from './main.svelte';
import { create_custom_element } from 'svelte/internal'; import { create_custom_element } from 'svelte/internal';
export default function (target) { export default function (target) {
customElements.define('no-tag', create_custom_element(CustomElement, ['name'], [], [])); customElements.define('no-tag', create_custom_element(CustomElement, {name: {}}, [], []));
target.innerHTML = '<no-tag name="world"></no-tag>'; target.innerHTML = '<no-tag name="world"></no-tag>';
const el = target.querySelector('no-tag'); const el = target.querySelector('no-tag');

@ -3,7 +3,7 @@ import CustomElement from './main.svelte';
import { create_custom_element } from 'svelte/internal'; import { create_custom_element } from 'svelte/internal';
export default function (target) { export default function (target) {
customElements.define('no-tag', create_custom_element(CustomElement, ['name'], [], [])); customElements.define('no-tag', create_custom_element(CustomElement, { name: {}}, [], []));
target.innerHTML = '<no-tag name="world"></no-tag>'; target.innerHTML = '<no-tag name="world"></no-tag>';
const el = target.querySelector('no-tag'); const el = target.querySelector('no-tag');

@ -3,7 +3,7 @@ import CustomElement from './main.svelte';
import { create_custom_element } from 'svelte/internal'; import { create_custom_element } from 'svelte/internal';
export default function (target) { export default function (target) {
customElements.define('no-tag', create_custom_element(CustomElement, ['name'], [], [])); customElements.define('no-tag', create_custom_element(CustomElement, { name: {} }, [], []));
target.innerHTML = '<no-tag name="world"></no-tag>'; target.innerHTML = '<no-tag name="world"></no-tag>';
const el = target.querySelector('no-tag'); const el = target.querySelector('no-tag');

@ -1,8 +1,11 @@
<svelte:options tag="custom-element" /> <svelte:options
tag="custom-element"
cePropsDefinition={{ red: { reflect: true, type: "Boolean" } }}
/>
<script> <script>
import "./my-widget.svelte"; import "./my-widget.svelte";
export let red = false; export let red;
red; red;
</script> </script>

@ -1,4 +1,7 @@
<svelte:options tag="my-widget" /> <svelte:options
tag="my-widget"
cePropsDefinition={{ red: { reflect: true } }}
/>
<script> <script>
export let red = false; export let red = false;

Loading…
Cancel
Save