Henning Koch, makandra GmbH
@triskweline
Give it 10 minutes.
Based on chart by @ryanstout
Based on chart by @ryanstout
Based on chart by @ryanstout
Based on chart by @ryanstout
Based on chart by @ryanstout
Based on chart by @ryanstout
Based on chart by @ryanstout
Based on chart by @ryanstout
A look back at the 7 AngularJS projects that we wrote from 2013-2016:
In hindsight, these are the projects that should have been a SPA:
YMMV.
Link to demo app
(press Start Classic on first page)
Imagine HTML6 was all about server-side apps
What features would be in that spec?
Partial page updates?
Animated transitions?
Layered interactions?
We can polyfill all of that!
Because it's 2016 and JavaScript is now fast.
Link to demo app
(press Start Enhanced on first page)
up-*
prefix[up-target]
and
[up-modal]
Server renders full pages on every request.
Any state that's not in the URL gets lost when switching pages.
Server still renders full pages, but we only use fragments.
This solves most of our problems with transient state being destroyed.
Unpoly apps may stack up to three HTML pages on top of each other
Each layer has its own URL and can navigate without changing the others
Use this to keep context during interactions
<a href="/list" up-target=".main">Replace fragment</a>
<a href="/new" up-modal=".main">Open fragment in dialog</a>
<a href="/menu" up-popup=".main">Open fragment in dropdown</a>
Links in a layer prefer to update fragments within the layer
Changing a fragment behind the layer will close the layer
.up-current
class automatically.
Don't worry, we actually allow for massive customization:
Every app needs a way to pair JavaScript snippets
with certain HTML elements:
textarea.wysiwyg
should activate Redactor on loadinput[type=search]
field should automatically request new results
button.toggle-all
should toggle all checkboxes
when clicked
.map
should render a map via the Google Maps JS API
<div class="map"></span>
document.addEventListener('DOMContentLoaded', function(event) {
document.querySelectorAll('.map').forEach(function(element) {
new google.maps.Map(element)
})
})
This is what you see in JavaScript tutorials.
HTML fragments loaded via AJAX don't get Javascriptified.
<div class="map"></span>
up.compiler('.map', function(element) {
new google.maps.Map(element)
})
Unpoly automatically compiles all fragments that it inserts or updates.
On the first page load, the entire document is compiled.
<div class="map"></div>
<script type="text/javascript">
initMap(document.querySelector('.map'), { lat: 48.75, lng: 11.45 })
</script>
function initMap(element, center) {
new google.maps.Map(element, { center: center })
})
<div class="map" up-data="{ lat: 48.75, lng: 11.45 }"</div>
up.compiler('.map', function(element, center) {
new google.maps.Map(element, center: center)
})
The [up-data]
attribute value is parsed as JSON and passed
to your compiler as a JavaScript object.
If you are using some CSS component architecture like BEM you will find a nice symmetry between your CSS components and Unpoly compilers:
app/assets/stylesheets/blocks
carousel.css
head.css
map.css
screenshot.css
tail.css
app/assets/javascripts/compilers
carousel.js
head.js
map.js
screenshot.js
By sticking to this pattern you will always know where to find the CSS / JS that affects
your
<div class='map'>...</div>
element.
We want to approximate the snappiness of a AngularJS SPA
(since we're happy with those). How fast is an SPA?
.up-active
class while loading
.up-active
for instantaneous feedback[up-instant]
attribute load on mousedown
instead of click
[up-preload]
attribute preload destination while hovering
GET
requests are cached for 5 minutes
GET
request clears the entire cache
Feel the response time of an Unpoly app by navigating between cards on
makandracards.com/makandra.
Paste this into the console to visualize mousedown events:
function showEvent() {
var $div = $('<div>mousedown!</div>');
$div.css({ backgroundColor: 'blue', color: 'white', fontSize: '20px', padding: '20px', position: 'fixed', left: '0', top: '0', zIndex: '99999999' });
$div.appendTo(document.body);
$div.fadeOut(500, function() { $div.remove() });
};
document.addEventListener('mousedown', showEvent, { capture: true });
<html>
<body>
<% if up.target?('.side') %>
<div class='side'>
...
</div>
<% end %>
<% if up.target?('.main') %>
<div class='main'>
...
</div>
<% end %>
</body>
</html>
up.target?(css)
looks at the X-Up-Target
HTTP header
that Unpoly
sends with every request.
For the occasional long-running request, you can configure this globally:
<div class="spinner">Please wait!</div>
up.compiler('.spinner', function(element) {
function show() { element.style.display = 'block' }
function hide() { element.style.display = 'none' }
up.on('up:proxy:slow', show)
up.on('up:proxy:recover', hide)
hide()
});
The up:proxy:slow
event is triggered after 300 ms (configurable).
<div class='greeter'>
<input class='greeter--input'>
Hello <span class='greeter--name'><span>!
</div>
up.compiler('.greeter', function(element) {
let input = element.querySelector('.greeter--input')
let name = element.querySelector('.greeter--name')
input.addEventListener('input', function() {
name.textContent = input.value
})
})
With Rivets.js (6 KB):
<div class='template' up-data='{ "name": "Arne" }'>
<input rv-value='name'>
Hello { name }!
</div>
up.compiler('.template', function(element, data) {
let view = rivets.bind(element, data)
return function() { view.unbind } // clean up
})
Homegrown UJS syntax usually lacks composability.
Changing that was a major design goal for Unpoly.
Unpoly's default UJS behavior is a small layer around a JS API.
You can use this JS API to call Unpoly from your own code:
<a href="full.html" up-target=".story">Continue</a>
up.replace('.story', 'full.html')
$(document).on('up:modal:open', function(event) {
if (dontLikeModals()) {
event.preventDefault()
}
})
<a menu-link href="/details">Show more</span>
up.compiler('[menu-link]', function(element) {
element.addEventListener('click', function(event) {
event.preventDefault();
up.popup.attach(element, {
target: '.menu',
position: 'bottom-left',
animation: 'roll-down'
});
});
});
View full documentation of JS functions,
events and UJS selectors on
unpoly.com.
When a new element enters the DOM, you can animate the appearance:
<a href="/settings" up-modal=".menu" up-animation="fade-in">
Open settings
</a>
When you swap an element, you can transition between the old and new states:
<a href="/users" up-target=".list" up-transition="cross-fade">
Show users
</a>
Animations are implemented via CSS transforms on a 3D-accelerated layer.
Painful things with forms:
These are all solved by Unpoly.
A form with [up-target]
will be submitted via AJAX
and leave surrounding elements intact:
<form method="post" action="/users" up-target=".main">
...
</form>
A successful submission (status 200) will update .main
A failed submission (non-200 status) will update the form itself
(Or use an [up-fail-target]
attribute)
class UsersController < ApplicationController
def create
user_params = params[:user].permit(:email, :password)
@user = User.new(user_params)
if @user.save?
sign_in @user
else
render 'form', status: :bad_request
end
end
end
To stay within the modal, target a selector within the modal:
<form up-target=".selector-within-modal">
...
</form>
To close the modal, target a selector behind the modal:
<form up-target=".selector-behind-modal">
...
</form>
<form action="/users">
<fieldset>
<label>E-mail</label>
<input type="text" name="email" up-validate>
</label>
<fieldset>
<label>Password</label>
<input type="password" name="password">
</fieldset>
<button type="submit">Register</button>
</form>
<form action="/users">
<fieldset>
<label>E-mail</label>
<input type="text" name="email" up-validate>
</label>
<fieldset>
<label>Password</label>
<input type="password" name="password">
</fieldset>
<button type="submit">Register</button>
</form>
<form action="/users">
<fieldset class="has-error">
<label>E-mail has already been taken!</label>
<input type="text" name="email" up-validate value="foo@bar.com">
</label>
<fieldset>
<label>Password</label>
<input type="password" name="password">
</fieldset>
<button type="submit">Register</button>
</form>
class UsersController < ApplicationController
def create
@user = User.new(user_params)
if @user.save
sign_in @user
else
render 'form', status: :bad_request
end
end
end
class UsersController < ApplicationController
def create
@user = User.new(user_params)
if up.validate?
@user.valid? # run validations, but don't save to DB
render 'form' # render form with error messages
elsif @user.save?
sign_in @user
else
render 'form', status: :bad_request
end
end
end
Use [up-switch]
to show or hide existing elements based on a control value:
<select name="advancedness" up-switch=".target">
<option value="basic">Basic parts</option>
<option value="advanced">Advanced parts</option>
</select>
<div class="target" up-show-for="basic">
only shown for advancedness = basic
</div>
<div class="target" up-hide-for="basic">
hidden for advancedness = basic
</div>
Use [up-validate]
to update a field from the server when a control value changes.
In the example below we load a list of employees when the user selects a department:
<form action="/contracts">
<select name="department" up-validate="[name=employee]">...</select>
<select name="employee">...</select>
</form>
npm install unpoly --save
or gem 'unpoly-rails'
(other methods)[up-target]
attributesup.compiler()
<div class="story">
<p>Story summary</p>
<a href="full.html" up-target=".story">
Read full story
</a>
</div>
<p>This text won't change</p>
<div class="story">
<h1>Full story</h1>
<p>Lorem ipsum dolor sit amet.</p>
<a href="short.html" up-target=".story">
Read summary
</a>
</div>
full.html
via AJAX.story
https://host/full.html
<div class="story">
<p>Story summary</p>
<a href="full.html" up-modal=".story">
Read full story
</a>
</div>
full.html
via AJAX.story
<div class="up-modal">
https://host/full.html
<div class="story">
<p>Story summary</p>
<a href="full.html" up-popup=".story">
Read full story
</a>
</div>
full.html
via AJAX.story
<div class="up-popup">
https://host/full.html
up.replace('.story', 'full.html').then(function() {
// Fragments were loaded and swapped
});
up.morph('.old', '.new', 'cross-fade').then(function() {
// Transition has completed
});
<div class="tasks">
<li>Wash car</li>
<li>Purchase supplies</li>
<li>Fix tent</li>
</ul>
<a class="next-page" href="/tasks?page=2"
up-target=".tasks:after, .next-page">Next page</a>
This appends the second page to the task list and replaces the "Next page" button with a link to page 3.
<div class="story">
<video up-keep src="movie.mp4"></video>
<p>Story summary</p>
<a href="full.html" up-target=".story">
Read full story
</a>
</div>
<div class="map" up-data="[
{ lat: 48.36, lng: 10.99 },
{ lat: 48.75, lng: 11.45 }
]"></div>
<form method="post" action="/pins" up-target=".map">
Lat: <input name="lat">
Lng: <input name="lng">
<button>Add pin</button>
</form>
up.compiler('.map', function(element, pins) {
var map = new google.maps.Map(element)
pins.forEach(function(pin) {
var position = new google.maps.LatLng(pin.lat, pin.lng);
new google.maps.Marker({
position: position,
map: map
})
})
<div class="map" up-data="<%= @pins.to_json %>"></div>
<form method="post" action="/pins" up-target=".map">
Lat: <input name="lat">
Lng: <input name="lng">
<button>Add pin</button>
</form>
up.compiler('.map', function(element, pins) {
var map = new google.maps.Map(element);
pins.forEach(function(pin) {
var position = new google.maps.LatLng(pin.lat, pin.lng)
new google.maps.Marker({
position: position,
map: map
})
})
<div class="map" up-data="<%= @pins.to_json %>"></div>
<%= form_for Pin.new, html: { 'up-target' => '.map' } do |form| %>
Lat: <%= form.text_field :lat %>
Lng: <%= form.text_field :lng %>
<%= form.submit 'Add pin' %>
<% end %>
up.compiler('.map', function(element, pins) {
var map = new google.maps.Map(element)
pins.forEach(function(pin) {
var position = new google.maps.LatLng(pin.lat, pin.lng)
new google.maps.Marker({
position: position,
map: map
})
})
<div class="map" up-data="<%= @pins.to_json %>"></div>
<%= form_for Pin.new, html: { 'up-target' => '.map' } do |form| %>
Lat: <%= form.text_field :lat %>
Lng: <%= form.text_field :lng %>
<%= form.submit 'Add pin' %>
<% end %>
up.compiler('.map', function(element, initialPins) {
var map = new google.maps.Map(element)
function renderPins(pins) { ... }
renderPins(initialPins)
});
<div class="map" up-data="<%= @pins.to_json %>" up-keep></div>
<%= form_for Pin.new, html: { 'up-target' => '.map' } do |form| %>
Lat: <%= form.text_field :lat %>
Lng: <%= form.text_field :lng %>
<%= form.submit 'Add pin' %>
<% end %>
up.compiler('.map', function($element, initialPins) {
var map = new google.maps.Map($element);
function renderPins(pins) { ... }
renderPins(initialPins)
element.addEventListener('up:fragment:keep', function(event) {
renderPins(event.newData)
})
})
<form action="/users" up-target=".list" up-autosubmit>
<input type="search" name="query" />
</form>
<div class="list">
<% @users.each do |user| %>
<%= link_to user.email, user >
<% end %>
</div>
<clock></clock>
$.unobtrusive(function() {
$(this).find('clock', function() {
var $clock = $(this);
function updateClock() {
var now = new Date();
$clock.html(now.toString());
}
setInterval(updateClock, 1000);
});
});
<clock></clock>
$.unobtrusive(function() {
$(this).find('clock', function() {
var $clock = $(this);
function updateClock() {
var now = new Date();
$clock.html(now.toString());
}
setInterval(updateClock, 1000); // creates one interval per <clock>!
});
});
<clock></clock>
up.compiler('clock', function(clock) {
function updateClock() {
var now = new Date()
clock.textContent = now.toString()
}
setInterval(updateClock, 1000) // this still leaks memory!
});
<clock></clock>
up.compiler('clock', function(clock) {
function updateClock() {
var now = new Date()
clock.textContent = now.toString()
}
var interval = setInterval(updateClock, 1000)
return function() { clearInterval(interval) } // clean up when destroyed
})
up.compiler('textarea.wysiwyg', function(textarea) {
$R(textarea)
})
up.compiler('textarea.wysiwyg', function(textarea) {
$R(textarea)
return function() {
$R(textarea, 'destroy')
}
})
fade-in
fade-out
move-to-top
move-from-bottom
move-to-bottom
move-from-top
move-to-left
move-from-right
move-to-right
move-from-left
cross-fade
move-top
move-bottom
move-left
move-right
up.animation('zoom-in', function(element, options) {
var firstFrame = {
opacity: 0,
transform: 'scale(0.5)'
}
var lastFrame = {
opacity: 1,
transform: 'scale(1)'
}
up.element.setStyle(element, firstFrame)
return up.animate(element, lastFrame, options)
})
<span class="toggle-all">Toggle all</span>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.toggle-all').forEach(function(element) {
element.addEventListener('click', function() {
let form = element.closest('form');
let checkboxes = form.querySelectorAll('input[type=checkox]');
let someUnchecked = !!checkboxes.find(function(cb) { !cb.checked }
checkboxes.forEach(function(cb) {
cb.checked = someUnchecked
})
})
})
})
Run example on codepen.io.
This is what you see in jQuery tutorials.
HTML fragments loaded via AJAX don't get Javascriptified.
<span class="toggle-all">Toggle all</span>
app.directive('toggle-all', function() {
return {
restrict: 'C',
link: function(scope, $link) {
$link.on('click', function() {
var $form = $link.closest('form')
var $checkboxes = $form.find(':checkbox')
var someUnchecked = $checkboxes.is(':not(:checked)')
$checkboxes.prop('checked', someUnchecked)
})
}
}
})
It's nice how Angular lets us register a compiling function for a CSS selector.
Also we don't need to manually Javascriptify new fragments
as long as we insert them through Angular templates
<span class="toggle-all">Toggle all</span>
up.compiler('.toggle-all', function(element) {
element.addEventListener('click', function() {
let form = element.closest('form');
let checkboxes = form.querySelectorAll('input[type=checkox]');
let someUnchecked = !!checkboxes.find(function(cb) { !cb.checked }
checkboxes.forEach(function(cb) {
cb.checked = someUnchecked
})
})
})
Unpoly automatically compiles all fragments that it inserts or updates.
Unpoly gracefully degrades with old versions of Internet Explorer:
Edge | Full support |
---|---|
IE 10 | Full support |
IE 9 |
Page updates that change browser history fall back to a classic page load. Animations and transitions are skipped. |
IE8 | Unpoly prevents itself from booting, leaving you with a classic server-side application |
Include unpoly-rails In your Gemfile
:
gem 'unpoly-rails'
In your controllers, views and helpers:
up? # request.headers['X-Up-Target'].present?
up.target # request.headers['X-Up-Target']
up.title = 'Title from server' # response.headers['X-Up-Title'] = 'Title ...'
up.validate? # request.headers['X-Up-Validate'].present?
The gem also provides the JS and CSS assets for the latest Unpoly.
Although the Rails bindings are nice, Unpoly works with any kind of backend.
E.g. unpoly.com is a static middleman site using Unpoly.
Use Jasmine to describe examples.
Use jasmine-jquery to create sample elements.
Use up.hello
to compile sample elements.
up.compiler('.current-year', function($element) {
var year = new Date().getFullYear();
$element.text(year);
});
describe('.current-year', function() {
it("displays today's year", function() {
$element = affix('.current-today');
up.hello($element);
year = new Date().getFullYear();
expect($element).toHaveText(year.toString());
});
});
Disable animation:
up.motion.config.enabled = false;
Disable concurrent requests:
up.proxy.config.maxRequests = 1;
Wait before you do things:
AfterStep do
sleep 0.05 while page.evaluate_script('window.up && up.proxy.isBusy()')
end
(Or use patiently
).
Use jasmine-ajax to mock the network:
up.compiler('.server-time', function($element) {
$element.text('Loading ...');
up.ajax('/time').then(function(time) { $element.text(time) };
});
describe('.current-year', function() {
it('fetches and displays the current time from the server', function() {
jasmine.Ajax.install();
var $element = affix('.server-time');
up.hello($element);
expect($element).toHaveText('Loading...');
jasmine.Ajax.requests.mostRecent().respondWith({
status: 200,
contentType: 'text/plain',
responseText: '13:37:00'
});
expect($element).toHaveText('13:37:00');
});
});
Miller 1968; Card et al. 1991; Jacob Nielsen 1993
Also see Google's RAIL Performance Model.
<a menu-link href="/details">Show more</span>
up.macro('[menu-link]', function($link) {
$link.attr(
'up-target': '.menu',
'up-position': 'bottom-left',
'up-animation': 'roll-down'
});
});
☑ You are not writing super ambitious UI
☑ You have some control over the UI requirements
☑ You're ready to launch 100% of your JavaScript from up.compiler
☑ You're OK with dealing with the occasional breaking change
Is your alternative home-growing an informal Random.js framework?