Skip to content

Commit

Permalink
introduce svelte:option cePropsDefinition
Browse files Browse the repository at this point in the history
  • Loading branch information
dummdidumm committed Apr 6, 2023
1 parent 1131584 commit 82de3f6
Show file tree
Hide file tree
Showing 14 changed files with 192 additions and 74 deletions.
6 changes: 5 additions & 1 deletion elements/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1589,7 +1589,11 @@ export interface SvelteHTMLElements {
'svelte:document': HTMLAttributes<Document>;
'svelte:body': HTMLAttributes<HTMLElement>;
'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 };

[name: string]: { [name: string]: any };
Expand Down
43 changes: 41 additions & 2 deletions src/compiler/compile/Component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -45,6 +45,7 @@ interface ComponentOptions {
immutable?: boolean;
accessors?: boolean;
preserveWhitespace?: boolean;
cePropsDefinition?: Record<string, { reflect?: boolean; type?: 'String' | 'Boolean' | 'Number' | 'Array' | 'Object', attribute?: string }>;
}

const regex_leading_directory_separator = /^[/\\]/;
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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);

Expand Down
5 changes: 5 additions & 0 deletions src/compiler/compile/compiler_errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}'?)` : '')
Expand Down
36 changes: 7 additions & 29 deletions src/compiler/compile/render_dom/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}]));`
);
}

Expand Down
94 changes: 58 additions & 36 deletions src/runtime/internal/Component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ if (typeof HTMLElement === 'function') {
private $$connected = false;
private $$data = {};
private $$reflecting = false;
private $$boolean_props: string[] = [];
private $$props_definition: Record<string, CustomElementPropDefinition> = {};

constructor(
private $$componentCtor: ComponentType,
Expand Down Expand Up @@ -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: {
Expand All @@ -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];
}

Expand All @@ -244,47 +244,70 @@ 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<string, CustomElementPropDefinition>, 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`)
* @returns A custom element class
*/
export function create_custom_element(
Component: ComponentType,
props: (string | { name: string; type: 'boolean' })[],
props_definition: Record<string, CustomElementPropDefinition>,
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;
Expand All @@ -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);
}
};

Expand All @@ -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);
// <c-e camelCase="foo" /> will be ce.camcelcase = "foo"
const lower = prop.toLowerCase();
if (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 => {
Expand Down
17 changes: 17 additions & 0 deletions test/custom-elements/samples/camel-case-attribute/main.svelte
Original file line number Diff line number Diff line change
@@ -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}
24 changes: 24 additions & 0 deletions test/custom-elements/samples/camel-case-attribute/test.js
Original file line number Diff line number Diff line change
@@ -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>')
}
12 changes: 12 additions & 0 deletions test/custom-elements/samples/ce-options-valid/main.svelte
Original file line number Diff line number Diff line change
@@ -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>
Loading

0 comments on commit 82de3f6

Please sign in to comment.