[feature] Dynamic elements implementation <svelte:element> (#6898)

Closes #2324

Co-authored-by: Alfred Ringstad <alfred.ringstad@hyperlab.se>
Co-authored-by: Simon Holthausen <simon.holthausen@accso.de>
Co-authored-by: tanhauhau <lhtan93@gmail.com>
pull/7434/head
Yuichiro Yamashita 2 years ago committed by GitHub
parent 54197c5a1f
commit e0d93254fd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -1627,6 +1627,28 @@ If `this` is falsy, no component is rendered.
<svelte:component this={currentSelection.component} foo={bar}/>
```
### `<svelte:element>`
```sv
<svelte:element this={expression}/>
```
---
The `<svelte:element>` element lets you render an element of a dynamically specified type. This is useful for example when rich text content from a CMS. If the tag is changed, the children will be preserved unless there's a transition attached to the element. Any properties and event listeners present will be applied to the element.
The only supported binding is `bind:this`, since the element type specific bindings that Svelte does at build time (e.g. `bind:value` for input elements) does not work with a dynamic tag type.
If `this` has a nullish value, a warning will be logged in development mode.
```sv
<script>
let tag = 'div';
export let handler;
</script>
<svelte:element this={tag} on:click={handler}>Foo</svelte:element>
```
### `<svelte:window>`

@ -0,0 +1,18 @@
<script>
const options = ['h1', 'h3', 'p'];
let selected = options[0];
</script>
<select bind:value={selected}>
{#each options as option}
<option value={option}>{option}</option>
{/each}
</select>
{#if selected === 'h1'}
<h1>I'm a h1 tag</h1>
{:else if selected === 'h3'}
<h3>I'm a h3 tag</h3>
{:else if selected === 'p'}
<p>I'm a p tag</p>
{/if}

@ -0,0 +1,12 @@
<script>
const options = ['h1', 'h3', 'p'];
let selected = options[0];
</script>
<select bind:value={selected}>
{#each options as option}
<option value={option}>{option}</option>
{/each}
</select>
<svelte:element this={selected}>I'm a {selected} tag</svelte:element>

@ -0,0 +1,23 @@
---
title: <svelte:element>
---
Sometimes we don't know in advance what kind of DOM element to render. `<svelte:element>` comes in handy here. Instead of a sequence of `if` blocks...
```html
{#if selected === 'h1'}
<h1>I'm a h1 tag</h1>
{:else if selected === 'h3'}
<h3>I'm a h3 tag</h3>
{:else if selected === 'p'}
<p>I'm a p tag</p>
{/if}
```
...we can have a single dynamic component:
```html
<svelte:element this={selected}>I'm a {selected} tag</svelte:element>
```
The `this` value can be any string, or a falsy value — if it's falsy, no element is rendered.

@ -246,6 +246,10 @@ export default {
code: 'invalid-animation',
message: 'An element that uses the animate directive must be the sole child of a keyed each block'
},
invalid_animation_dynamic_element: {
code: 'invalid-animation',
message: '<svelte:element> cannot have a animate directive'
},
invalid_directive_value: {
code: 'invalid-directive-value',
message: 'Can only bind to an identifier (e.g. `foo`) or a member expression (e.g. `foo.bar` or `foo[baz]`)'

@ -18,6 +18,9 @@ import Let from './Let';
import TemplateScope from './shared/TemplateScope';
import { INode } from './interfaces';
import Component from '../Component';
import Expression from './shared/Expression';
import { string_literal } from '../utils/stringify';
import { Literal } from 'estree';
import compiler_warnings from '../compiler_warnings';
import compiler_errors from '../compiler_errors';
@ -190,11 +193,26 @@ export default class Element extends Node {
children: INode[];
namespace: string;
needs_manual_style_scoping: boolean;
tag_expr: Expression;
get is_dynamic_element() {
return this.name === 'svelte:element';
}
constructor(component: Component, parent: Node, scope: TemplateScope, info: any) {
super(component, parent, scope, info);
this.name = info.name;
if (info.name === 'svelte:element') {
if (typeof info.tag !== 'string') {
this.tag_expr = new Expression(component, this, scope, info.tag);
} else {
this.tag_expr = new Expression(component, this, scope, string_literal(info.tag) as Literal);
}
} else {
this.tag_expr = new Expression(component, this, scope, string_literal(this.name) as Literal);
}
this.namespace = get_namespace(parent as Element, this, component.namespace);
if (this.namespace !== namespaces.foreign) {

@ -48,6 +48,7 @@ export default class Block {
hydrate: Array<Node | Node[]>;
mount: Array<Node | Node[]>;
measure: Array<Node | Node[]>;
restore_measurements: Array<Node | Node[]>;
fix: Array<Node | Node[]>;
animate: Array<Node | Node[]>;
intro: Array<Node | Node[]>;
@ -96,6 +97,7 @@ export default class Block {
hydrate: [],
mount: [],
measure: [],
restore_measurements: [],
fix: [],
animate: [],
intro: [],
@ -326,6 +328,12 @@ export default class Block {
${this.chunks.measure}
}`;
if (this.chunks.restore_measurements.length) {
properties.restore_measurements = x`function #restore_measurements(#measurement) {
${this.chunks.restore_measurements}
}`;
}
properties.fix = x`function #fix() {
${this.chunks.fix}
}`;
@ -379,6 +387,7 @@ export default class Block {
m: ${properties.mount},
p: ${properties.update},
r: ${properties.measure},
s: ${properties.restore_measurements},
f: ${properties.fix},
a: ${properties.animate},
i: ${properties.intro},

@ -26,6 +26,7 @@ import Action from '../../../nodes/Action';
import MustacheTagWrapper from '../MustacheTag';
import RawMustacheTagWrapper from '../RawMustacheTag';
import is_dynamic from '../shared/is_dynamic';
import create_debugging_comment from '../shared/create_debugging_comment';
import { push_array } from '../../../../utils/push_array';
interface BindingGroup {
@ -134,6 +135,8 @@ const events = [
}
];
const CHILD_DYNAMIC_ELEMENT_BLOCK = 'child_dynamic_element';
export default class ElementWrapper extends Wrapper {
node: Element;
fragment: FragmentWrapper;
@ -147,6 +150,9 @@ export default class ElementWrapper extends Wrapper {
var: any;
void: boolean;
child_dynamic_element_block?: Block = null;
child_dynamic_element?: ElementWrapper = null;
constructor(
renderer: Renderer,
block: Block,
@ -156,6 +162,24 @@ export default class ElementWrapper extends Wrapper {
next_sibling: Wrapper
) {
super(renderer, block, parent, node);
if (node.is_dynamic_element && block.type !== CHILD_DYNAMIC_ELEMENT_BLOCK) {
this.child_dynamic_element_block = block.child({
comment: create_debugging_comment(node, renderer.component),
name: renderer.component.get_unique_name('create_dynamic_element'),
type: CHILD_DYNAMIC_ELEMENT_BLOCK
});
renderer.blocks.push(this.child_dynamic_element_block);
this.child_dynamic_element = new ElementWrapper(
renderer,
this.child_dynamic_element_block,
parent,
node,
strip_whitespace,
next_sibling
);
}
this.var = {
type: 'Identifier',
name: node.name.replace(/[^a-zA-Z0-9_$]/g, '_')
@ -199,6 +223,8 @@ export default class ElementWrapper extends Wrapper {
block.add_animation();
}
block.add_dependencies(node.tag_expr.dependencies);
// add directive and handler dependencies
[node.animation, node.outro, ...node.actions, ...node.classes, ...node.styles].forEach(directive => {
if (directive && directive.expression) {
@ -221,6 +247,7 @@ export default class ElementWrapper extends Wrapper {
node.handlers.length > 0 ||
node.styles.length > 0 ||
this.node.name === 'option' ||
node.tag_expr.dynamic_dependencies().length ||
renderer.options.dev
) {
this.parent.cannot_use_innerhtml(); // need to use add_location
@ -232,6 +259,110 @@ export default class ElementWrapper extends Wrapper {
}
render(block: Block, parent_node: Identifier, parent_nodes: Identifier) {
if (this.child_dynamic_element) {
this.render_dynamic_element(block, parent_node, parent_nodes);
} else {
this.render_element(block, parent_node, parent_nodes);
}
}
render_dynamic_element(block: Block, parent_node: Identifier, parent_nodes: Identifier) {
this.child_dynamic_element.render(
this.child_dynamic_element_block,
null,
(x`#nodes` as unknown) as Identifier
);
const previous_tag = block.get_unique_name('previous_tag');
const tag = this.node.tag_expr.manipulate(block);
block.add_variable(previous_tag, tag);
block.chunks.init.push(b`
${this.renderer.options.dev && b`@validate_dynamic_element(${tag});`}
let ${this.var} = ${tag} && ${this.child_dynamic_element_block.name}(#ctx);
`);
block.chunks.create.push(b`
if (${this.var}) ${this.var}.c();
`);
if (this.renderer.options.hydratable) {
block.chunks.claim.push(b`
if (${this.var}) ${this.var}.l(${parent_nodes});
`);
}
block.chunks.mount.push(b`
if (${this.var}) ${this.var}.m(${parent_node || '#target'}, ${parent_node ? 'null' : '#anchor'});
`);
const anchor = this.get_or_create_anchor(block, parent_node, parent_nodes);
const has_transitions = !!(this.node.intro || this.node.outro);
const not_equal = this.renderer.component.component_options.immutable ? x`@not_equal` : x`@safe_not_equal`;
block.chunks.update.push(b`
if (${tag}) {
if (!${previous_tag}) {
${this.var} = ${this.child_dynamic_element_block.name}(#ctx);
${this.var}.c();
${has_transitions && b`@transition_in(${this.var})`}
${this.var}.m(${this.get_update_mount_node(anchor)}, ${anchor});
} else if (${not_equal}(${previous_tag}, ${tag})) {
${this.var}.d(1);
${this.renderer.options.dev && b`@validate_dynamic_element(${tag});`}
${this.var} = ${this.child_dynamic_element_block.name}(#ctx);
${this.var}.c();
${this.var}.m(${this.get_update_mount_node(anchor)}, ${anchor});
} else {
${this.var}.p(#ctx, #dirty);
}
} else if (${previous_tag}) {
${
has_transitions
? b`
@group_outros();
@transition_out(${this.var}, 1, 1, () => {
${this.var} = null;
});
@check_outros();
`
: b`
${this.var}.d(1);
${this.var} = null;
`
}
}
${previous_tag} = ${tag};
`);
if (this.child_dynamic_element_block.has_intros) {
block.chunks.intro.push(b`@transition_in(${this.var});`);
}
if (this.child_dynamic_element_block.has_outros) {
block.chunks.outro.push(b`@transition_out(${this.var});`);
}
block.chunks.destroy.push(b`if (${this.var}) ${this.var}.d(detaching)`);
if (this.node.animation) {
const measurements = block.get_unique_name('measurements');
block.add_variable(measurements);
block.chunks.measure.push(b`${measurements} = ${this.var}.r()`);
block.chunks.fix.push(b`${this.var}.f();`);
block.chunks.animate.push(b`
${this.var}.s(${measurements});
${this.var}.a()
`);
}
}
is_dom_node() {
return super.is_dom_node() && !this.child_dynamic_element;
}
render_element(block: Block, parent_node: Identifier, parent_nodes: Identifier) {
const { renderer } = this;
if (this.node.name === 'noscript') return;
@ -249,7 +380,7 @@ export default class ElementWrapper extends Wrapper {
if (renderer.options.hydratable) {
if (parent_nodes) {
block.chunks.claim.push(b`
${node} = ${this.get_claim_statement(parent_nodes)};
${node} = ${this.get_claim_statement(block, parent_nodes)};
`);
if (!this.void && this.node.children.length > 0) {
@ -357,6 +488,8 @@ export default class ElementWrapper extends Wrapper {
b`@add_location(${this.var}, ${renderer.file_var}, ${loc.line - 1}, ${loc.column}, ${this.node.start});`
);
}
block.renderer.dirty(this.node.tag_expr.dynamic_dependencies());
}
can_use_textcontent() {
@ -364,7 +497,7 @@ export default class ElementWrapper extends Wrapper {
}
get_render_statement(block: Block) {
const { name, namespace } = this.node;
const { name, namespace, tag_expr } = this.node;
if (namespace === namespaces.svg) {
return x`@svg_element("${name}")`;
@ -379,22 +512,32 @@ export default class ElementWrapper extends Wrapper {
return x`@element_is("${name}", ${is.render_chunks(block).reduce((lhs, rhs) => x`${lhs} + ${rhs}`)})`;
}
return x`@element("${name}")`;
const reference = tag_expr.manipulate(block);
return x`@element(${reference})`;
}
get_claim_statement(nodes: Identifier) {
get_claim_statement(block: Block, nodes: Identifier) {
const attributes = this.attributes
.filter((attr) => !(attr instanceof SpreadAttributeWrapper) && !attr.property_name)
.map((attr) => p`${(attr as StyleAttributeWrapper | AttributeWrapper).name}: true`);
const name = this.node.namespace
? this.node.name
: this.node.name.toUpperCase();
let reference;
if (this.node.tag_expr.node.type === 'Literal') {
if (this.node.namespace) {
reference = `"${this.node.tag_expr.node.value}"`;
} else {
reference = `"${(this.node.tag_expr.node.value as String || '').toUpperCase()}"`;
}
} else if (this.node.namespace) {
reference = x`${this.node.tag_expr.manipulate(block)}`;
} else {
reference = x`(${this.node.tag_expr.manipulate(block)} || 'null').toUpperCase()`;
}
if (this.node.namespace === namespaces.svg) {
return x`@claim_svg_element(${nodes}, "${name}", { ${attributes} })`;
return x`@claim_svg_element(${nodes}, ${reference}, { ${attributes} })`;
} else {
return x`@claim_element(${nodes}, "${name}", { ${attributes} })`;
return x`@claim_element(${nodes}, ${reference}, { ${attributes} })`;
}
}
@ -847,6 +990,11 @@ export default class ElementWrapper extends Wrapper {
${rect} = ${this.var}.getBoundingClientRect();
`);
if (block.type === CHILD_DYNAMIC_ELEMENT_BLOCK) {
block.chunks.measure.push(b`return ${rect}`);
block.chunks.restore_measurements.push(b`${rect} = #measurement;`);
}
block.chunks.fix.push(b`
@fix_position(${this.var});
${stop_animation}();
@ -940,7 +1088,7 @@ export default class ElementWrapper extends Wrapper {
if (should_cache) {
block.chunks.update.push(b`
if (${block.renderer.dirty(dependencies)} && (${cached_snippet} !== (${cached_snippet} = ${snippet}))) {
${updater}
${updater}
}
`);
} else {

@ -8,8 +8,9 @@ import Expression from '../../nodes/shared/Expression';
import remove_whitespace_children from './utils/remove_whitespace_children';
import fix_attribute_casing from '../../render_dom/wrappers/Element/fix_attribute_casing';
import { namespaces } from '../../../utils/namespaces';
import { Expression as ESExpression } from 'estree';
export default function(node: Element, renderer: Renderer, options: RenderOptions) {
export default function (node: Element, renderer: Renderer, options: RenderOptions) {
const children = remove_whitespace_children(node.children, node.next);
@ -22,7 +23,8 @@ export default function(node: Element, renderer: Renderer, options: RenderOption
node.attributes.some((attribute) => attribute.name === 'contenteditable')
);
renderer.add_string(`<${node.name}`);
renderer.add_string('<');
add_tag_name();
const class_expression_list = node.classes.map(class_directive => {
const { expression, name } = class_directive;
@ -167,14 +169,25 @@ export default function(node: Element, renderer: Renderer, options: RenderOption
renderer.add_expression(node_contents);
}
if (!is_void(node.name)) {
renderer.add_string(`</${node.name}>`);
}
add_close_tag();
} else {
renderer.render(children, options);
add_close_tag();
}
function add_close_tag() {
if (!is_void(node.name)) {
renderer.add_string(`</${node.name}>`);
renderer.add_string('</');
add_tag_name();
renderer.add_string('>');
}
}
function add_tag_name() {
if (node.tag_expr.node.type === 'Literal') {
renderer.add_string(node.tag_expr.node.value as string);
} else {
renderer.add_expression(node.tag_expr.node as ESExpression);
}
}
}

@ -99,6 +99,10 @@ export default {
code: `invalid-${slug}-content`,
message: `<${name}> cannot have children`
}),
invalid_element_definition: {
code: 'invalid-element-definition',
message: 'Invalid element definition'
},
invalid_element_placement: (slug: string, name: string) => ({
code: `invalid-${slug}-placement`,
message: `<${name}> tags cannot be inside elements or blocks`
@ -161,6 +165,10 @@ export default {
code: 'missing-attribute-value',
message: 'Expected value for the attribute'
},
missing_element_definition: {
code: 'missing-element-definition',
message: '<svelte:element> must have a \'this\' attribute'
},
unclosed_script: {
code: 'unclosed-script',
message: '<script> must have a closing tag'

@ -19,7 +19,7 @@ const meta_tags = new Map([
['svelte:body', 'Body']
]);
const valid_meta_tags = Array.from(meta_tags.keys()).concat('svelte:self', 'svelte:component', 'svelte:fragment');
const valid_meta_tags = Array.from(meta_tags.keys()).concat('svelte:self', 'svelte:component', 'svelte:fragment', 'svelte:element');
const specials = new Map([
[
@ -41,6 +41,7 @@ const specials = new Map([
const SELF = /^svelte:self(?=[\s/>])/;
const COMPONENT = /^svelte:component(?=[\s/>])/;
const SLOT = /^svelte:fragment(?=[\s/>])/;
const ELEMENT = /^svelte:element(?=[\s/>])/;
function parent_is_head(stack) {
let i = stack.length;
@ -84,7 +85,7 @@ export default function tag(parser: Parser) {
parser.current().children.length
) {
parser.error(
parser_errors.invalid_element_content(slug, name),
parser_errors.invalid_element_content(slug, name),
parser.current().children[0].start
);
}
@ -169,7 +170,7 @@ export default function tag(parser: Parser) {
if (name === 'svelte:component') {
const index = element.attributes.findIndex(attr => attr.type === 'Attribute' && attr.name === 'this');
if (!~index) {
if (index === -1) {
parser.error(parser_errors.missing_component_definition, start);
}
@ -181,6 +182,19 @@ export default function tag(parser: Parser) {
element.expression = definition.value[0].expression;
}
if (name === 'svelte:element') {
const index = element.attributes.findIndex(attr => attr.type === 'Attribute' && attr.name === 'this');
if (index === -1) {
parser.error(parser_errors.missing_element_definition, start);
}
const definition = element.attributes.splice(index, 1)[0];
if (definition.value === true) {
parser.error(parser_errors.invalid_element_definition, definition.start);
}
element.tag = definition.value[0].data || definition.value[0].expression;
}
// special cases top-level <script> and <style>
if (specials.has(name) && parser.stack.length === 1) {
const special = specials.get(name);
@ -247,6 +261,7 @@ function read_tag_name(parser: Parser) {
}
if (parser.read(COMPONENT)) return 'svelte:component';
if (parser.read(ELEMENT)) return 'svelte:element';
if (parser.read(SLOT)) return 'svelte:fragment';
@ -258,7 +273,7 @@ function read_tag_name(parser: Parser) {
const match = fuzzymatch(name.slice(7), valid_meta_tags);
parser.error(
parser_errors.invalid_tag_name_svelte_element(valid_meta_tags, match),
parser_errors.invalid_tag_name_svelte_element(valid_meta_tags, match),
start
);
}

@ -107,6 +107,12 @@ export function validate_slots(name, slot, keys) {
}
}
export function validate_dynamic_element(tag: unknown) {
if (tag && typeof tag !== 'string') {
throw new Error('<svelte:element> expects "this" attribute to be a string.');
}
}
type Props = Record<string, any>;
export interface SvelteComponentDev {
$set(props?: Props): void;

@ -0,0 +1,21 @@
export default {
warnings: [
{
code: 'css-unused-selector',
end: {
character: 86,
column: 8,
line: 7
},
frame:
' 5: color: red;\n 6: }\n 7: .unused {\n ^\n 8: font-style: italic;\n 9: }',
message: 'Unused CSS selector ".unused"',
pos: 79,
start: {
character: 79,
column: 1,
line: 7
}
}
]
};

@ -0,0 +1 @@
.used.svelte-xyz{color:red}

@ -0,0 +1 @@
<div class="used svelte-xyz"></div>

@ -0,0 +1,10 @@
<svelte:element this="div" class="used" />
<style>
.used {
color: red;
}
.unused {
font-style: italic;
}
</style>

@ -15,4 +15,4 @@ const Component = create_ssr_component(($$result, $$props, $$bindings, slots) =>
<p>foo: ${escape(foo)}</p>`;
});
export default Component;
export default Component;

@ -0,0 +1,2 @@
<svelte:element this="div"></svelte:element>
<svelte:element this="div" class="foo"></svelte:element>

@ -0,0 +1,50 @@
{
"html": {
"start": 0,
"end": 101,
"type": "Fragment",
"children": [
{
"start": 0,
"end": 44,
"type": "Element",
"name": "svelte:element",
"attributes": [],
"children": [],
"tag": "div"
},
{
"type": "Text",
"start": 44,
"end": 45,
"data": "\n",
"raw": "\n"
},
{
"start": 45,
"end": 101,
"type": "Element",
"name": "svelte:element",
"attributes": [
{
"type": "Attribute",
"start": 72,
"end": 83,
"name": "class",
"value": [
{
"type": "Text",
"start": 79,
"end": 82,
"data": "foo",
"raw": "foo"
}
]
}
],
"children": [],
"tag": "div"
}
]
}
}

@ -0,0 +1,2 @@
<svelte:element this={tag}></svelte:element>
<svelte:element this={tag} class="foo"></svelte:element>

@ -0,0 +1,80 @@
{
"html": {
"start": 0,
"end": 101,
"type": "Fragment",
"children": [
{
"start": 0,
"end": 44,
"type": "Element",
"name": "svelte:element",
"attributes": [],
"children": [],
"tag": {
"start": 22,
"end": 25,
"loc": {
"start": {
"line": 1,
"column": 22
},
"end": {
"line": 1,
"column": 25
}
},
"name": "tag",
"type": "Identifier"
}
},
{
"type": "Text",
"start": 44,
"end": 45,
"data": "\n",
"raw": "\n"
},
{
"start": 45,
"end": 101,
"type": "Element",
"name": "svelte:element",
"children": [],
"attributes": [
{
"type": "Attribute",
"start": 72,
"end": 83,
"name": "class",
"value": [
{
"type": "Text",
"start": 79,
"end": 82,
"data": "foo",
"raw": "foo"
}
]
}
],
"tag": {
"start": 67,
"end": 70,
"loc": {
"start": {
"line": 2,
"column": 22
},
"end": {
"line": 2,
"column": 25
}
},
"name": "tag",
"type": "Identifier"
}
}
]
}
}

@ -1,6 +1,6 @@
{
"code": "invalid-tag-name",
"message": "Valid <svelte:...> tag names are svelte:head, svelte:options, svelte:window, svelte:body, svelte:self, svelte:component or svelte:fragment",
"message": "Valid <svelte:...> tag names are svelte:head, svelte:options, svelte:window, svelte:body, svelte:self, svelte:component, svelte:fragment or svelte:element",
"pos": 10,
"start": {
"character": 10,

@ -0,0 +1,45 @@
let logs = [];
export default {
html: `
<h1>tag is h1.</h1>
`,
props: {
logs
},
after_test() {
logs = [];
},
async test({ assert, component, target }) {
assert.equal(component.tag, 'h1');
assert.deepEqual(logs, ['create: h1,opt1']);
component.opt = 'opt2';
assert.equal(component.tag, 'h1');
assert.deepEqual(logs, ['create: h1,opt1', 'update: h1,opt2']);
component.tag = 'h2';
assert.equal(component.tag, 'h2');
assert.deepEqual(logs, [
'create: h1,opt1',
'update: h1,opt2',
'destroy',
'create: h2,opt2'
]);
assert.htmlEqual(target.innerHTML, '<h2>tag is h2.</h2>');
component.tag = false;
assert.deepEqual(logs, [
'create: h1,opt1',
'update: h1,opt2',
'destroy',
'create: h2,opt2',
'destroy'
]);
assert.htmlEqual(target.innerHTML, '');
}
};

@ -0,0 +1,14 @@
<script>
export let logs = [];
export let tag = "h1";
export let opt = "opt1";
function foo(node, {tag, opt}) {
logs.push(`create: ${tag},${opt}`);
return {
update: ({tag, opt}) => logs.push(`update: ${tag},${opt}`),
destroy: () => logs.push('destroy'),
};
}
</script>
<svelte:element this={tag} use:foo={{tag, opt}}>tag is {tag}.</svelte:element>

@ -0,0 +1,105 @@
let originalDivGetBoundingClientRect;
let originalSpanGetBoundingClientRect;
let originalParagraphGetBoundingClientRect;
export default {
skip_if_ssr: true,
props: {
things: [
{ id: 1, name: 'a' },
{ id: 2, name: 'b' },
{ id: 3, name: 'c' },
{ id: 4, name: 'd' },
{ id: 5, name: 'e' }
],
tag: 'div'
},
html: `
<div>a</div>
<div>b</div>
<div>c</div>
<div>d</div>
<div>e</div>
`,
before_test() {
originalDivGetBoundingClientRect =
window.HTMLDivElement.prototype.getBoundingClientRect;
originalSpanGetBoundingClientRect =
window.HTMLSpanElement.prototype.getBoundingClientRect;
originalParagraphGetBoundingClientRect =
window.HTMLParagraphElement.prototype.getBoundingClientRect;
window.HTMLDivElement.prototype.getBoundingClientRect =
fakeGetBoundingClientRect;
window.HTMLSpanElement.prototype.getBoundingClientRect =
fakeGetBoundingClientRect;
window.HTMLParagraphElement.prototype.getBoundingClientRect =
fakeGetBoundingClientRect;
function fakeGetBoundingClientRect() {
const index = [...this.parentNode.children].indexOf(this);
const top = index * 30;
return {
left: 0,
right: 100,
top,
bottom: top + 20
};
}
},
after_test() {
window.HTMLDivElement.prototype.getBoundingClientRect =
originalDivGetBoundingClientRect;
window.HTMLSpanElement.prototype.getBoundingClientRect =
originalSpanGetBoundingClientRect;
window.HTMLParagraphElement.prototype.getBoundingClientRect =
originalParagraphGetBoundingClientRect;
},
async test({ assert, component, target, raf }) {
// switch tag and things at the same time
await component.update('p', [
{ id: 5, name: 'e' },
{ id: 2, name: 'b' },
{ id: 3, name: 'c' },
{ id: 4, name: 'd' },
{ id: 1, name: 'a' }
]);
const ps = document.querySelectorAll('p');
assert.equal(ps[0].dy, 120);
assert.equal(ps[4].dy, -120);
raf.tick(50);
assert.equal(ps[0].dy, 60);
assert.equal(ps[4].dy, -60);
raf.tick(100);
assert.equal(ps[0].dy, 0);
assert.equal(ps[4].dy, 0);
await component.update('span', [
{ id: 1, name: 'a' },
{ id: 2, name: 'b' },
{ id: 3, name: 'c' },
{ id: 4, name: 'd' },
{ id: 5, name: 'e' }
]);
const spans = document.querySelectorAll('span');
assert.equal(spans[0].dy, 120);
assert.equal(spans[4].dy, -120);
raf.tick(150);
assert.equal(spans[0].dy, 60);
assert.equal(spans[4].dy, -60);
raf.tick(200);
assert.equal(spans[0].dy, 0);
assert.equal(spans[4].dy, 0);
}
};

@ -0,0 +1,26 @@
<script>
export let things;
export let tag;
function flip(node, animation, params) {
const dx = animation.from.left - animation.to.left;
const dy = animation.from.top - animation.to.top;
return {
duration: 100,
tick: (t, u) => {
node.dx = u * dx;
node.dy = u * dy;
}
};
}
export function update(new_tag, new_things) {
things = new_things;
tag = new_tag;
}
</script>
{#each things as thing (thing.id)}
<svelte:element this={tag} animate:flip>{thing.name}</svelte:element>
{/each}

@ -0,0 +1,62 @@
export default {
props: {
things: [
{ id: 1, name: 'a' },
{ id: 2, name: 'b' },
{ id: 3, name: 'c' },
{ id: 4, name: 'd' },
{ id: 5, name: 'e' }
],
tag: 'div'
},
html: `
<div>a</div>
<div>b</div>
<div>c</div>
<div>d</div>
<div>e</div>
`,
test({ assert, component, target, raf }) {
component.tag = 'p';
assert.equal(target.querySelectorAll('p').length, 5);
component.tag = 'div';
let divs = target.querySelectorAll('div');
divs.forEach(div => {
div.getBoundingClientRect = function() {
const index = [...this.parentNode.children].indexOf(this);
const top = index * 30;
return {
left: 0,
right: 100,
top,
bottom: top + 20
};
};
});
component.things = [
{ id: 5, name: 'e' },
{ id: 2, name: 'b' },
{ id: 3, name: 'c' },
{ id: 4, name: 'd' },
{ id: 1, name: 'a' }
];
divs = target.querySelectorAll('div');
assert.ok(~divs[0].style.animation.indexOf('__svelte'));
assert.equal(divs[1].style.animation, '');
assert.equal(divs[2].style.animation, '');
assert.equal(divs[3].style.animation, '');
assert.ok(~divs[4].style.animation.indexOf('__svelte'));
raf.tick(100);
assert.deepEqual([
divs[0].style.animation,
divs[4].style.animation
], ['', '']);
}
};

@ -0,0 +1,18 @@
<script>
export let things;
export let tag;
function flip(node, animation, params) {
const dx = animation.from.left - animation.to.left;
const dy = animation.from.top - animation.to.top;
return {
duration: 100,
css: (t, u) => `transform: translate(${u + dx}px, ${u * dy}px)`
};
}
</script>
{#each things as thing (thing.id)}
<svelte:element this={tag} animate:flip>{thing.name}</svelte:element>
{/each}

@ -0,0 +1,17 @@
export default {
props: {
tag: 'div'
},
html: '<div style="color: red;">Foo</div>',
test({ assert, component, target }) {
component.tag = 'h1';
assert.htmlEqual(
target.innerHTML,
`
<h1 style="color: red;">Foo</h1>
`
);
}
};

@ -0,0 +1,5 @@
<script>
export let tag = 'div';
</script>
<svelte:element this={tag} style="color: red;">Foo</svelte:element>

@ -0,0 +1,3 @@
export default {
error: "'value' is not a valid binding on <svelte:element> elements"
};

@ -0,0 +1,6 @@
<script>
const tag = "div";
let value;
</script>
<svelte:element this={tag} bind:value></svelte:element>

@ -0,0 +1,8 @@
export default {
html: '<div></div>',
test({ assert, component, target }) {
const div = target.querySelector('div');
assert.equal(div, component.foo);
}
};

@ -0,0 +1,6 @@
<script>
const tag = "div";
export let foo;
</script>
<svelte:element this={tag} bind:this={foo}></svelte:element>

@ -0,0 +1,17 @@
export default {
props: {
tag: 'div'
},
html: '<div>Foo</div>',
test({ assert, component, target }) {
component.tag = 'h1';
assert.htmlEqual(
target.innerHTML,
`
<h1>Foo</h1>
`
);
}
};

@ -0,0 +1,5 @@
<script>
export let tag = 'div';
</script>
<svelte:element this={tag}>Foo</svelte:element>

@ -0,0 +1,5 @@
<script>
let tag = '';
</script>
<svelte:element this={tag}>Foo</svelte:element>

@ -0,0 +1,21 @@
let clicked = false;
function handler() {
clicked = true;
}
export default {
props: {
handler
},
html: '<button>Foo</button>',
test({ assert, target }) {
assert.equal(clicked, false);
const button = target.querySelector('button');
const click = new window.MouseEvent('click');
button.dispatchEvent(click);
assert.equal(clicked, true);
}
};

@ -0,0 +1,6 @@
<script>
const tag = "button";
export let handler;
</script>
<svelte:element this={tag} on:click={handler}>Foo</svelte:element>

@ -0,0 +1,23 @@
let clicked = false;
function handler() {
clicked = true;
}
export default {
props: {
tag: 'div',
handler
},
html: '<div>Foo</div>',
test({ assert, component, target }) {
assert.equal(clicked, false);
component.tag = 'button';
const button = target.querySelector('button');
const click = new window.MouseEvent('click');
button.dispatchEvent(click);
assert.equal(clicked, true);
}
};

@ -0,0 +1,6 @@
<script>
export let tag;
export let handler;
</script>
<svelte:element this={tag} on:click={handler}>Foo</svelte:element>

@ -0,0 +1,3 @@
export default {
html: '<div>Foo</div>'
};

@ -0,0 +1 @@
<svelte:element this={"div"}>Foo</svelte:element>

@ -0,0 +1,9 @@
export default {
compileOptions: {
dev: true
},
props: {
tag: 123
},
error: '<svelte:element> expects "this" attribute to be a string.'
};

@ -0,0 +1,5 @@
<script>
export let tag;
</script>
<svelte:element this={tag} />

@ -0,0 +1,5 @@
<script>
let tag = null;
</script>
<svelte:element this={tag}>Foo</svelte:element>

@ -0,0 +1,16 @@
let clicked = false;
export default {
props: {
tag: 'div',
onClick: () => clicked = true
},
html: '<div style="display: inline;">Foo</div>',
async test({ assert, target, window }) {
const div = target.querySelector('div');
await div.dispatchEvent(new window.MouseEvent('click'));
assert.equal(clicked, true);
}
};

@ -0,0 +1,6 @@
<script>
const tag = "div";
export let onClick;
</script>
<svelte:element this={tag} style="display: inline;" on:click={onClick}>Foo</svelte:element>

@ -0,0 +1,7 @@
<h1>Foo</h1>
<div id="default">
<slot></slot>
</div>
<div id="other">
<slot name='other'></slot>
</div>

@ -0,0 +1,29 @@
export default {
props: {
x: true
},
html: `
<h1>Foo</h1>
<div id="default">
<h1>This is default slot</h1>
</div>
<div id="other">
<h1 slot='other'>This is other slot</h1>
</div>
`,
test({ assert, component, target }) {
component.tag = 'h2';
assert.htmlEqual(target.innerHTML, `
<h1>Foo</h1>
<div id="default">
<h2>This is default slot</h2>
</div>
<div id="other">
<h2 slot='other'>This is other slot</h2>
</div>
`);
}
};

@ -0,0 +1,10 @@
<script>
import Foo from './Foo.svelte';
export let tag = "h1";
</script>
<Foo>
<svelte:element this={tag}>This is default slot</svelte:element>
<svelte:element this={tag} slot='other'>This is other slot</svelte:element>
</Foo>

@ -0,0 +1,3 @@
export default {
html: '<div></div>'
};

@ -0,0 +1,6 @@
<script>
import { writable } from 'svelte/store';
const foo = writable('div');
</script>
<svelte:element this={$foo}></svelte:element>

@ -0,0 +1,3 @@
export default {
html: '<div>Foo</div>'
};

@ -0,0 +1 @@
<svelte:element this="div">Foo</svelte:element>

@ -0,0 +1,21 @@
export default {
props: {
size: 1
},
html: '<h1>This is h1 tag</h1>',
test({ assert, component, target }) {
const h1 = target.firstChild;
component.size = 2;
assert.htmlEqual(
target.innerHTML,
`
<h2>This is h2 tag</h2>
`
);
const h2 = target.firstChild;
assert.notEqual(h1, h2);
}
};

@ -0,0 +1,5 @@
<script>
export let size;
</script>
<svelte:element this="{`h${size}`}">This is h{size} tag</svelte:element>

@ -0,0 +1,17 @@
export default {
test({ assert, component, target, raf }) {
component.visible = true;
const h1 = target.querySelector('h1');
assert.equal(h1.style.animation, '__svelte_3809512021_0 100ms linear 0ms 1 both');
raf.tick(150);
component.tag = 'h2';
const h2 = target.querySelector('h2');
assert.equal(h1.style.animation, '');
assert.equal(h2.style.animation, '');
raf.tick(50);
component.visible = false;
assert.equal(h2.style.animation, '__svelte_3750847757_0 100ms linear 0ms 1 both');
}
};

@ -0,0 +1,17 @@
<script>
export let tag = "h1";
export let visible;
function foo() {
return {
duration: 100,
css: t => {
return `opacity: ${t}`;
}
};
}
</script>
{#if visible}
<svelte:element this={tag} transition:foo></svelte:element>
{/if}

@ -0,0 +1,19 @@
export default {
html: '',
test({ component, target, assert }) {
component.tag = 'h1';
assert.htmlEqual(target.innerHTML, '<h1>Foo</h1>');
component.tag = null;
assert.htmlEqual(target.innerHTML, '');
component.tag = 'div';
assert.htmlEqual(target.innerHTML, '<div>Foo</div>');
component.tag = false;
assert.htmlEqual(target.innerHTML, '');
component.tag = 'span';
assert.htmlEqual(target.innerHTML, '<span>Foo</span>');
}
};

@ -0,0 +1,5 @@
<script>
export let tag;
</script>
<svelte:element this={tag}>Foo</svelte:element>

@ -0,0 +1,20 @@
export default {
props: {
tag: 'div',
text: 'Foo'
},
html: '<div>Foo</div>',
test({ assert, component, target }) {
const div = target.firstChild;
component.tag = 'nav';
component.text = 'Bar';
assert.htmlEqual(target.innerHTML, `
<nav>Bar</nav>
`);
const h1 = target.firstChild;
assert.notEqual(div, h1);
}
};

@ -0,0 +1,6 @@
<script>
export let tag = "div";
export let text = "Foo";
</script>
<svelte:element this={tag}>{text}</svelte:element>

@ -0,0 +1 @@
<svelte:element this="div">Foo</svelte:element>

@ -0,0 +1,7 @@
<script>
let heading = 'h1';
let tag = 'div';
</script>
<svelte:element this={heading}>Foo</svelte:element>
<svelte:element this={tag}>Bar</svelte:element>

@ -0,0 +1,17 @@
[
{
"message": "Invalid element definition",
"code": "invalid-element-definition",
"start": {
"line": 2,
"column": 17,
"character": 23
},
"end": {
"line": 2,
"column": 17,
"character": 23
},
"pos": 23
}
]

@ -0,0 +1,3 @@
<div>
<svelte:element this>foo</svelte:element>
</div>

@ -0,0 +1,15 @@
[{
"code": "missing-element-definition",
"message": "<svelte:element> must have a 'this' attribute",
"start": {
"line": 2,
"column": 1,
"character": 7
},
"end": {
"line": 2,
"column": 1,
"character": 7
},
"pos": 7
}]

@ -0,0 +1,3 @@
<div>
<svelte:element>foo</svelte:element>
</div>

@ -0,0 +1,15 @@
[{
"code": "unexpected-reserved-word",
"message": "'this' is a reserved word in JavaScript and cannot be used here",
"start": {
"line": 2,
"column": 18,
"character": 24
},
"end": {
"line": 2,
"column": 18,
"character": 24
},
"pos": 24
}]

@ -0,0 +1,3 @@
<div>
<svelte:element {this}>foo</svelte:element>
</div>
Loading…
Cancel
Save