Commit 87cc1f6b authored by S Anand's avatar S Anand

ENH: $.template supports virtualdom. Fixes #126

parent 33e015bd
Pipeline #71676 passed with stage
in 2 minutes and 53 seconds
......@@ -34,9 +34,46 @@ using as `.template({var1: value, var2: value, ...})`. For example:
To re-render the template, run `.template(data)` again with different data.
## $.template options
## $.template animation
To re-use the template, i.e. render the same template on a different DOM node,
When using `type="text/html"`, templates are re-rendered. To *update* existing
elements, use `data-engine="vdom"` instead. This only changes attributes or
elements that need change. This allows us to animate attributes via CSS.
You need to include [morphdom](https://github.com/patrick-steele-idem/morphdom)
for this to work.
For example, this shows a circle in SVG bouncing around smoothly.
```html
<style>
circle { transition: all 1s ease; }
</style>
<script src="../node_modules/morphdom/dist/morphdom-umd.min.js"></script>
<script type="text/html">
<svg width="100" height="100">
<circle cx="<%= x %>" cy="<%= y %>" r="5" fill="red"/>
</svg>
</script>
<script>
setInterval(function() {
var x = Math.random() * 100
var y = Math.random() * 100
$('body').template({x: x, y: y}) // Update the template to animate
}, 1000)
</script>
```
You can also specify a `data-engine` via an option. For example:
```js
$('script.animate').template(data, {engine: 'vdom'})
```
## $.template targets
To re-use the template or render the same template on a different DOM node,
run `.template(data, {target: selector})`. This allows you to declare templates
once and apply them across the body. For example:
......@@ -47,19 +84,54 @@ $('script.chart')
.template({}, {target: '.no-heading'})
```
The target can also be specified via a `data-target=".dashboard1"` on the script
template. This is the same as specifying `{target: '.dashboard'}`. For example:
```html
<script class="chart" data-target=".dashboard1">...</script>
<script class="chart" data-target=".dashboard2">...</script>
```
## $.template append
To append instead of replacing, run `.template(data, {append: true})`. Every
time `.template` is called, it appends rather than replaces. For example:
```js
$('script.list')
.template({heading: 'Item 1'}, {append: true}), // Appends the heading
.template({heading: 'Item 2'}, {append: true}), // instead of replacing it
```
You can also specify this as `<script data-append="true">`. This helps append to
an existing target. For example:
```html
<script class="list" data-append="true" data-target=".existing-list">...</script>
<ul class="existing list">
<li>Existing item</li>
<!-- Every time .template() is called, the result is added as a list item here -->
</ul>
```
## $.template attributes
## $.template external source
Containers support these attributes:
Template containers can have an `src=` attribute that loads the template from a file:
- `data-selector` defines the triggers, i.e. which nodes $.template applies to. Default: `script[type="text/html"]`
- `src` optionally loads the template from a file or URL. e.g. `<script type="text/html" src="template.html"></script>`
```html
<script type="text/html" src="template.html"></script>
<script>
$('body').template()
</script>
```
If the `src=` URL returns a HTTP error, the HTML *inside* the script is rendered as
a template. The template can use:
If the `src=` URL returns a HTTP error, the HTML *inside* the script is rendered
as a template. The template can use:
- all data passed by the `$().template()` function, and
- an [xhr](http://api.jquery.com/Types/#jqXHR) object - which has error details
- an [xhr](http://api.jquery.com/Types/#jqXHR) object - which has error details.
For example:
......@@ -73,6 +145,29 @@ For example:
</script>
```
## $.template selector
`$().template()` renders all `script[type="text/html"]` nodes in or under the
selected node. Use `data-selector=` attribute to change the selector. For
example:
```html
<section data-selector="script.lodash-template">
<script class="lodash-template">...</script>
</section>
<script>
$('section').template()
</script>
```
You can also render a template by selecting it directly. For example:
```html
<script>
$('script.lodash-template').template()
</script>
```
## $.template events
......
......@@ -45,6 +45,7 @@
"leaflet": "1.3",
"lodash": "4",
"moment": "2",
"morphdom": "2",
"numeral": "2",
"popper.js": "1",
"puppeteer": "0.13",
......
import { findall } from './_util.js'
var _renderer = 'template.render',
_target = 'template.target'
// Bind a template renderer to the node $this.data('template.render')
// The _renderer function
// - runs the html template, parses the result, and create a target node
// - appends the target node after $this (clearing any previous target nodes)
// - stores the target node in $this.data('template.target')
// - triggers a template event (with .templatedata, .target)
// - returns the target node
function make_template($this, html, data, default_options) {
var template = _.template(html)
var $target
if (!default_options)
default_options = {}
function renderer(data, options) {
// Parse the template output and create a node collection
// $.parseHTML ensures that "hello" is parsed as HTML, not a selector
$target = $($.parseHTML(template(data)))
// Get options. DOM over-rides JS options
var append = $this.data('append') || (options ? options.append : default_options.append)
var target = $this.data('target') || (options ? options.target : default_options.target)
// Render into target node if specified
// Else, render immediately after source node
if (typeof target != 'undefined') {
if (append)
$(target).append($target)
else
$(target).html($target)
} else {
// Clear old target nodes if any
var $old_target = $this.data(_target)
if ($old_target)
$old_target.remove()
// Add the target HTML. "before" ensures that future appends are one below the other
$this.before($target)
// Store the target nodes for future reference
if (!append)
$this.data(_target, $target)
}
// Trigger the template event
$this.trigger({type: 'template', templatedata: data, target: $target})
return $target
}
$this.data(_renderer, renderer)
return renderer(data)
}
export function template(data, options) {
var selector = this.data('selector') || 'script[type="text/html"]'
......@@ -63,9 +15,9 @@ export function template(data, options) {
// If the AJAX load succeeds, render the loaded template
// Else render the contents, with an additional xhr variable
$.get(src)
.done(function(html) { make_template($this, html, data, options) })
.fail(function(xhr) {
make_template($this, $this.html(), _.extend({xhr: xhr}, data, options))
.done(function (html) { make_template($this, html, data, options) })
.fail(function (xhr) {
make_template($this, $this.html(), _.extend({ xhr: xhr }, data, options))
})
} else
// If no src= is specified, just render the contents
......@@ -76,3 +28,111 @@ export function template(data, options) {
})
return this
}
var _renderer = 'template.render'
var _prev_target = 'template.prev_target'
// Bind a template renderer to the node $this.data('template.render')
// This renderer function accepts (data, options) and creates
// - runs the html template, parses the result, and create a target node
// - appends the target node after $this (clearing any previous target nodes)
// - stores the target node in $this.data('template.target')
// - triggers a template event (with .templatedata, .target)
// - returns the target node
function make_template($this, html, data, default_options) {
var compiled_template = _.template(html)
var $target
if (!default_options)
default_options = {}
function renderer(data, options) {
html = compiled_template(data)
// Get options. DOM data-* over-rides JS options
var append = $this.data('append') || (options ? options.append : default_options.append)
var target = $this.data('target') || (options ? options.target : default_options.target)
var engine = $this.data('engine') || (options ? options.engine : default_options.engine)
if (!engine || typeof engine == 'string')
engine = template.engines[engine] || template.engines['default']
// If we're appending the contents, just add the text
if (append) {
// If we're appending to a target node, just append to it.
if (target)
$(target).append($($.parseHTML(html)))
// If no target node, add BEFORE template. Future appends will be in sequence
else
$this.before($($.parseHTML(html)))
}
// If we're not appending, replace the contents using the renderer
else {
// The engine must return the target nodes. See template.engines spec below
$target = engine($this, target, html)
// Store the target nodes for future reference. See template.engines spec below
$this.data(_prev_target, $target)
}
// Trigger the template event. Use "templatedata" since ".data" is reserved
$this.trigger({ type: 'template', templatedata: data, target: $target })
return $target
}
$this.data(_renderer, renderer)
return renderer(data)
}
// $.fn.template.engines is a registry of rendering engines. Each entry is a
// function that accepts 3 parameters:
// $this: the <script> element
// target: the target selector or node to render into. May be undefined
// html: the HTML to render at the target (or around $this if target is missing)
// It returns the target nodes created as a jQuery object. This is used in 2 ways:
// - the template event.target attribute is this return value
// - $this.data(_prev_target) is set to this return value
template.engines = {}
// The default engine uses jQuery
template.engines['default'] = template.engines['jquery'] = function ($this, target, html) {
// Parse the template output and create a node collection
// $.parseHTML ensures that "hello" is parsed as HTML, not a selector
var $target = $($.parseHTML(html))
// If target exists, replace the HTML. Otherwise, create new nodes before the template.
if (target)
$(target).html($target)
else {
// Remove any previous targets and re-create the output
var $oldtarget = $this.data(_prev_target)
if ($oldtarget)
$oldtarget.remove()
$this.before($target)
}
return $target
}
/* globals morphdom */
template.engines['vdom'] = function ($this, target, html) {
// If no target is specified, use the previous target, if any
target = target || $this.data(_prev_target)
// If a target is specified, wrap the HTML with the target node.
// For example, <div id="target">...</div> will wrap the HTML with
// <div id="target"></div>
var $target, tag_open, tag_close
if (target) {
$target = $(target)
var node = $target.get(0)
tag_open = '<' + node.nodeName
$.each(node.attributes, function () {
tag_open += ' ' + this.name + '=' + this.value
})
tag_open += '>'
tag_close = '</' + node.nodeName + '>'
}
// If a target is not specified, Create the target node before the template.
// Wrap the HTML and the node with <div> to ensure that it's a single node.
// Morphdom requires a single node.
else {
tag_open = '<div>'
tag_close = '</div>'
$target = $(tag_open + tag_close).insertBefore($this)
}
$target.each(function () {
morphdom(this, tag_open + html + tag_close)
})
return $target
}
......@@ -4,14 +4,19 @@
<title>template tests</title>
<script src="../node_modules/jquery/dist/jquery.min.js"></script>
<script src="../node_modules/lodash/lodash.min.js"></script>
<script src="../node_modules/morphdom/dist/morphdom-umd.min.js"></script>
<script src="../dist/template.min.js"></script>
<script src="tape.js"></script>
<style>
circle, rect { transition: all 0.5s ease; }
</style>
</head>
<body>
<script>
tape.onFinish(function () { window.renderComplete = true })
</script>
<h1>Lodash templates</h1>
<script type="text/html" id="t1">Your platform is <%= navigator.userAgent %></script>
<script>
function strip(text) {
......@@ -22,20 +27,28 @@
return node ? strip(node.textContent) : ''
}
tape('$().template() renders plain text with variables', function(t) {
t.plan(2)
$('#t1').one('template.g1', function(e) {
t.equal(e.target.length, 1)
t.equal(text(e.target), 'Your platform is ' + navigator.userAgent)
})
.template()
$('#t1')
.one('template.g1', function (e) {
t.equal(e.target.length, 1)
t.equal(text(e.target), 'Your platform is ' + navigator.userAgent)
})
.template()
.one('template.g1', function (e) {
t.equal(e.target.length, 1)
t.equal(text(e.target), 'Your platform is abc')
t.end()
})
.template({navigator: {userAgent: 'abc'}})
})
</script>
<script type="text/html" id="t2">
<% list.forEach(function(item) { %>
<div><%= item %></div>
<% }) %>
</script>
<div id="t2">
<script type="text/html">
<% list.forEach(function(item) { %>
<div><%= item %></div>
<% }) %>
</script>
</div>
<script>
tape('$().template(data) passes data to the template', function (t) {
var suffixes = ['x', 'y']
......@@ -48,10 +61,8 @@
var text = $divs.map(function () { return this.innerHTML }).get().join(' ')
t.equal(text, list.join(' '), 'Template content is correct')
t.deepEqual(e.templatedata, {list: list})
e.target
.filter('div')
.attr('class', 'item')
t.equal($('.item').length, 3, 'Repeated calls over-write the same node')
$divs.attr('class', 'my-unique-item')
t.equal($('.my-unique-item').length, 3, 'Repeated calls over-write the same node')
}).template({ list: list })
})
})
......@@ -106,7 +117,7 @@
})
</script>
<section class="root1">
<section id="root1">
<script type="text/html"><%= 1 + 1 %></script>
<script type="text/html"><%= 2 + 2 %></script>
</section>
......@@ -115,20 +126,20 @@
var results = ['2', '4']
t.plan(results.length)
var index = 0
$('.root1').on('template', function (e) {
$('#root1').on('template', function (e) {
t.equal(text(e.target), results[index++])
}).template()
})
</script>
<section class="root2" data-selector="script.template">
<section id="root2" data-selector="script.template">
<script type="text/html"><div class="result"><%= 1 + 1 %></div></script>
<script type="text/html" class="template"><div class="result"><%= 2 + 2 %></div></script>
</section>
<script>
tape('$().template() accepts data-selector to select sub-elements', function (t) {
// The second template (with .template) is triggered, not the first one
$('.root2').on('template', function (e) {
$('#root2').on('template', function (e) {
t.equal(e.target.filter('.result').text(), '4')
t.end()
}).template()
......@@ -154,35 +165,113 @@
})
</script>
<script class="append-dom" data-append="true" type="text/html"><span class="appended-dom"><%= n %></span></script>
<script class="append-dom" data-append="true" data-target=".append-dom-target" type="text/html"><span class="appended-dom-target"><%= n %></span></script>
<script class="append-dom" data-append="true" type="text/html"><i class="new-dom"><%= n %></i><i class="new-dom"><%= n %></i></script>
<script class="append-dom" data-append="true" data-target=".append-dom-target" type="text/html"><i class="new-dom-target"><%= n %></i><i class="new-dom-target"><%= n %></i></script>
<div class="append-dom-target"></div>
<script>
tape('$().template() with data-append="true" appends data to target', function (t) {
var count = 5
for (var i = 0; i < count; i++) {
$('script.append-dom').template({ n: i }, {append: false})
$('script.append-dom').template(
{ n: i },
{ append: false } // append=false is over-ridden by data-append
)
}
t.equal($('.appended-dom').length, count)
t.equal($('.append-dom-target .appended-dom-target').length, count)
t.equal($('.new-dom').length, count * 2)
t.equal($('.append-dom-target .new-dom-target').length, count * 2)
t.end()
})
</script>
<script class="append-js" type="text/html"><span class="appended-js"><%= n %></span></script>
<script class="append-js" data-target=".append-js-target" type="text/html"><span class="appended-js-target"><%= n %></span></script>
<script class="append-js" type="text/html"><i class="new-js"><%= n %></i><i class="new-js"><%= n %></i></script>
<script class="append-js" data-target=".append-js-target" type="text/html"><i class="new-js-target"><%= n %></i><i class="new-js-target"><%= n %></i></script>
<div class="append-js-target"></div>
<script>
tape('$().template() with {append:true} option appends data to target', function (t) {
var count = 5
for (var i = 0; i < count; i++) {
$('script.append-js').template({ n: i }, {append: true})
$('script.append-js').template(
{ n: i },
{ append: true }
)
}
t.equal($('.appended-js').length, count)
t.equal($('.append-js-target .appended-js-target').length, count)
t.equal($('.new-js').length, count * 2)
t.equal($('.append-js-target .new-js-target').length, count * 2)
t.end()
})
</script>
<h1>Morphdom templates</h1>
<div class="morphdom-t1">
<script class="morphdom-attr" type="text/html" data-engine="vdom">
<svg width="100" height="10">
<circle cx="<%= x %>" cy="5" r="5" fill="<%= color %>"/>
<rect x="<%= 100 - x %>" y="0" width="10" height="10" fill="green"/>
</svg>
<span><%= x %></span>
<svg width="100" height="10">
<circle cx="<%= x %>" cy="5" r="5" fill="<%= color %>"/>
<rect x="<%= 100 - x %>" y="0" width="10" height="10" fill="green"/>
</svg>
</script>
<script class="morphdom-js" type="text/html">
<b><%= x %></b>
</script>
<script class="morphdom-none" type="text/html">
<i><%= x %></i>
</script>
</div>
<script>
tape('$().template() with type=text/vdom morphs the same node', function(t) {
$('.morphdom-t1 .morphdom-attr').template({ x: 0, color: 'white' })
$('.morphdom-t1 .morphdom-js').template({ x: 0 }, {engine: 'vdom'})
var $circle = $('.morphdom-t1 circle')
var $rect = $('.morphdom-t1 rect')
var $span = $('.morphdom-t1 span')
var $b = $('.morphdom-t1 b')
// Use Array() instead of []. Lack of semicolons associates it with previous line
Array(5, 50, 95).forEach(function(pos) {
$('.morphdom-t1 .morphdom-attr').template({ x: pos, color: 'red' })
$('.morphdom-t1 .morphdom-js').template({ x: pos }, { engine: 'vdom' })
$circle.each(function () {
t.equal($(this).attr('cx'), String(pos))
t.equal($(this).attr('fill'), 'red')
})
$rect.each(function () { t.equal($(this).attr('x'), String(100 - pos)) })
$span.each(function () { t.equal($(this).text(), String(pos)) })
$b.each(function () { t.equal($(this).text(), String(pos)) })
})
t.end()
})
</script>
<script type="text/html" class="animated-template" data-engine="vdom">
<svg class="vdom-result" width="100" height="10"><circle cx="<%= x %>" cy="5" r="5" fill="black"></circle></svg>
</script>
<div class="morphdom-target vdom" style="background-color:#afa"></div>
<div class="morphdom-target vdom" style="background-color:#afa"></div>
<script type="text/html" class="animated-template" data-target=".morphdom-target" data-engine="vdom">
<svg class="vdom-result" width="100" height="10"><circle cx="<%= x %>" cy="5" r="5" fill="black"></circle></svg>
</script>
<script>
var $tmpl = $('.animated-template').template({ x: 0 })
var circles = $('.vdom-result circle').get()
tape('$().template() with type=text/vdom animates nodes', function(t) {
t.deepEqual(circles.map(function (c) { return c.getBBox().x }), [-5, -5, -5])
// In the next cycle, start animation.
setTimeout(function () {
$tmpl.template({ x: 100 })
// After a short interval, check if the SVG element has moved.
// Default position is -5 (cx - r = 0 - 5 = -5)
setTimeout(function () {
var x = _.max(circles.map(function (c) { return c.getBBox().x }))
t.ok(x > -5, 'x > -5')
t.ok(x < 95, 'x < 95')
t.end()
}, 100)
}, 0)
})
</script>
</body>
</html>
......@@ -2710,6 +2710,11 @@ moment@2:
version "2.21.0"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.21.0.tgz#2a114b51d2a6ec9e6d83cf803f838a878d8a023a"
morphdom@2:
version "2.3.3"
resolved "https://registry.yarnpkg.com/morphdom/-/morphdom-2.3.3.tgz#70b0aec3db0832688f7fcbde2a8921cf508c4f16"
integrity sha512-z+/GEulEfhrSFPOJSum8o5lZNv63cAGBPeFHO2WgpGo636Ln67ZuVydp2q0iTaZIXdf5FDNP2ZY6uhtg+LjlsA==
ms@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment