Skip to content

Dynamic elements implementation <svelte:element> #5481

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions site/content/docs/02-template-syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -1548,6 +1548,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";
Copy link
Member

@benmccann benmccann Apr 27, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the docs generally use single quotes for JS strings

Suggested change
let tag = "div";
let tag = 'div';

I noticed this same thing in the tests. It might be nice to cleanup there as well though less important

export let handler;
</script>

<svelte:element this={tag} on:click={handler}>Foo</svelte:element>
```

### `<svelte:window>`

Expand Down
3 changes: 2 additions & 1 deletion src/compiler/compile/nodes/Animation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ import TemplateScope from './shared/TemplateScope';
import { TemplateNode } from '../../interfaces';
import Element from './Element';
import EachBlock from './EachBlock';
import DynamicElement from './DynamicElement';
import compiler_errors from '../compiler_errors';

export default class Animation extends Node {
type: 'Animation';
name: string;
expression: Expression;

constructor(component: Component, parent: Element, scope: TemplateScope, info: TemplateNode) {
constructor(component: Component, parent: Element | DynamicElement, scope: TemplateScope, info: TemplateNode) {
super(component, parent, scope, info);

component.warn_if_undefined(info.name, info, scope);
Expand Down
3 changes: 2 additions & 1 deletion src/compiler/compile/nodes/Binding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import Element from './Element';
import InlineComponent from './InlineComponent';
import Window from './Window';
import { clone } from '../../utils/clone';
import DynamicElement from './DynamicElement';
import compiler_errors from '../compiler_errors';

// TODO this should live in a specific binding
Expand All @@ -32,7 +33,7 @@ export default class Binding extends Node {
is_contextual: boolean;
is_readonly: boolean;

constructor(component: Component, parent: Element | InlineComponent | Window, scope: TemplateScope, info: TemplateNode) {
constructor(component: Component, parent: Element | InlineComponent | Window | DynamicElement, scope: TemplateScope, info: TemplateNode) {
super(component, parent, scope, info);

if (info.expression.type !== 'Identifier' && info.expression.type !== 'MemberExpression') {
Expand Down
151 changes: 151 additions & 0 deletions src/compiler/compile/nodes/DynamicElement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import Node from './shared/Node';
import Attribute from './Attribute';
import Binding from './Binding';
import EventHandler from './EventHandler';
import Let from './Let';
import TemplateScope from './shared/TemplateScope';
import { INode } from './interfaces';
import Expression from './shared/Expression';
import Component from '../Component';
import map_children from './shared/map_children';
import Class from './Class';
import Transition from './Transition';
import Animation from './Animation';
import Action from './Action';
import { string_literal } from '../utils/stringify';
import { Literal } from 'estree';
import Text from './Text';

export default class DynamicElement extends Node {
type: 'DynamicElement';
name: string;
tag: Expression;
attributes: Attribute[] = [];
actions: Action[] = [];
bindings: Binding[] = [];
classes: Class[] = [];
handlers: EventHandler[] = [];
lets: Let[] = [];
intro?: Transition = null;
outro?: Transition = null;
animation?: Animation = null;
children: INode[];
scope: TemplateScope;
needs_manual_style_scoping: boolean;

constructor(component: Component, parent, scope, info) {
super(component, parent, scope, info);

this.name = info.name;

if (typeof info.tag === 'string') {
this.tag = new Expression(component, this, scope, string_literal(info.tag) as Literal);
} else {
this.tag = new Expression(component, this, scope, info.tag);
}

info.attributes.forEach((node) => {
switch (node.type) {
case 'Action':
this.actions.push(new Action(component, this, scope, node));
break;

case 'Attribute':
case 'Spread':
this.attributes.push(new Attribute(component, this, scope, node));
break;

case 'Binding':
this.bindings.push(new Binding(component, this, scope, node));
break;

case 'Class':
this.classes.push(new Class(component, this, scope, node));
break;

case 'EventHandler':
this.handlers.push(new EventHandler(component, this, scope, node));
break;

case 'Let': {
const l = new Let(component, this, scope, node);
this.lets.push(l);
const dependencies = new Set([l.name.name]);

l.names.forEach((name) => {
scope.add(name, dependencies, this);
});
break;
}

case 'Transition': {
const transition = new Transition(component, this, scope, node);
if (node.intro) this.intro = transition;
if (node.outro) this.outro = transition;
break;
}

case 'Animation':
this.animation = new Animation(component, this, scope, node);
break;

default:
throw new Error(`Not implemented: ${node.type}`);
}
});

this.scope = scope;

this.children = map_children(component, this, this.scope, info.children);

this.validate();

// TODO create BaseElement class or an interface which both DynamicElement and Element use
// to resolve the hacky cast
component.apply_stylesheet(this as any);
}

validate() {
this.bindings.forEach(binding => {
if (binding.name !== 'this') {
this.component.error(binding, {
code: 'invalid-binding',
message: `'${binding.name}' is not a valid binding. svelte:element only supports bind:this`
});
}
});
}

add_css_class() {
if (this.attributes.some(attr => attr.is_spread)) {
this.needs_manual_style_scoping = true;
return;
}

const { id } = this.component.stylesheet;

const class_attribute = this.attributes.find(a => a.name === 'class');

if (class_attribute && !class_attribute.is_true) {
if (class_attribute.chunks.length === 1 && class_attribute.chunks[0].type === 'Text') {
(class_attribute.chunks[0] as Text).data += ` ${id}`;
} else {
(class_attribute.chunks as Node[]).push(
new Text(this.component, this, this.scope, {
type: 'Text',
data: ` ${id}`,
synthetic: true
} as any)
);
}
} else {
this.attributes.push(
new Attribute(this.component, this, this.scope, {
type: 'Attribute',
name: 'class',
value: [{ type: 'Text', data: id, synthetic: true }]
} as any)
);
}
}
}
2 changes: 2 additions & 0 deletions src/compiler/compile/nodes/Element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import Let from './Let';
import TemplateScope from './shared/TemplateScope';
import { INode } from './interfaces';
import Component from '../Component';
import Expression from './shared/Expression';
import compiler_warnings from '../compiler_warnings';
import compiler_errors from '../compiler_errors';

Expand Down Expand Up @@ -131,6 +132,7 @@ export default class Element extends Node {
children: INode[];
namespace: string;
needs_manual_style_scoping: boolean;
dynamic_tag?: Expression;

constructor(component: Component, parent: Node, scope: TemplateScope, info: any) {
super(component, parent, scope, info);
Expand Down
3 changes: 2 additions & 1 deletion src/compiler/compile/nodes/Transition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Component from '../Component';
import TemplateScope from './shared/TemplateScope';
import { TemplateNode } from '../../interfaces';
import Element from './Element';
import DynamicElement from './DynamicElement';
import compiler_errors from '../compiler_errors';

export default class Transition extends Node {
Expand All @@ -13,7 +14,7 @@ export default class Transition extends Node {
expression: Expression;
is_local: boolean;

constructor(component: Component, parent: Element, scope: TemplateScope, info: TemplateNode) {
constructor(component: Component, parent: Element | DynamicElement, scope: TemplateScope, info: TemplateNode) {
super(component, parent, scope, info);

component.warn_if_undefined(info.name, info, scope);
Expand Down
2 changes: 2 additions & 0 deletions src/compiler/compile/nodes/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import ThenBlock from './ThenBlock';
import Title from './Title';
import Transition from './Transition';
import Window from './Window';
import DynamicElement from './DynamicElement';

// note: to write less types each of types in union below should have type defined as literal
// https://www.typescriptlang.org/docs/handbook/unions-and-intersections.html#discriminating-unions
Expand All @@ -45,6 +46,7 @@ export type INode = Action
| Class
| Comment
| DebugTag
| DynamicElement
| EachBlock
| Element
| ElseBlock
Expand Down
2 changes: 2 additions & 0 deletions src/compiler/compile/nodes/shared/map_children.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import AwaitBlock from '../AwaitBlock';
import Body from '../Body';
import Comment from '../Comment';
import DynamicElement from '../DynamicElement';
import EachBlock from '../EachBlock';
import Element from '../Element';
import Head from '../Head';
Expand All @@ -25,6 +26,7 @@ function get_constructor(type) {
case 'AwaitBlock': return AwaitBlock;
case 'Body': return Body;
case 'Comment': return Comment;
case 'DynamicElement' : return DynamicElement;
case 'EachBlock': return EachBlock;
case 'Element': return Element;
case 'Head': return Head;
Expand Down
Loading