Building single page apps using web components
Polymer v0.4.2
Table of contents
So how do you build a single page application (SPA) with Polymer? On the Polymer team we get this question a lot. Our answer (as always) is…“use components!” However, it’s never immediately obvious how to tackle an existing problem with new technologies. How do you compose a bunch of modular components into a larger, functional app?
In this article, I’ll show you how to build a full-featured single page application that:
- Is built entirely using Polymer’s core elements
- Practices responsive design
- Transitions between views using data-binding features
- Features URL routing and deep linking
- Is keyboard accessible
- Loads content dynamically on-demand (optional)
If you like to eat dessert first, you can find the files for the finished demo here:
App structure
Designing a layout is one of the first tasks when starting a project. As part of its core element collection, Polymer
has several layout elements (<core-header-panel>
, <core-drawer-panel>
, <core-toolbar>
) for scaffolding an application’s structure. These components are useful by themselves, but for an even quicker start, we’re going to focus on <core-scaffold>
. It starts you off with a responsive mobile layout by assembling several of the foundational elements.
<core-scaffold>
’s children are arranged by specifying attributes and/or using specific tags. For example, using a <nav>
element creates the app drawer. Alternatively, you can use the navigation
attribute on any element (e.g <core-header-panel navigation>
). The toolbar is designated with the tool
attribute. All other children end up in the main content area.
<body unresolved fullbleed> <core-scaffold id="scaffold"> <nav>Left drawer</nav> <core-toolbar tool>Application</core-toolbar> <div>Main content</div> </core-scaffold> </body>
Let’s dive deeper on each of these sections.
Drawer
Markup that you put in the navigation element ends up in a slide-away app drawer.
For our purposes, we’ll stick with a heading (<core-toolbar>
) and navigational links (<core-menu>
):
<nav>
<core-toolbar><span>Single Page Polymer</span></core-toolbar>
<core-menu selected="0">
<paper-item noink>
<core-icon icon="label-outline"></core-icon>
<a href="#one">Single</a>
</paper-item>
<paper-item noink>
<core-icon icon="label-outline"></core-icon>
<a href="#two">page</a>
</paper-item>
...
</core-menu>
</nav>
Note Right now, <core-menu selected="0">
is hard-coded to select the first item. We’ll make that dynamic later.
Toolbar
A toolbar spans the top of the page and contains functional icon buttons. A perfect
element for that type of behavior is <core-toolbar>
:
<!-- flex makes the bar span across the top of the main content area -->
<core-toolbar tool flex>
<!-- flex spaces this element and justifies the icons to the right-side -->
<div flex>Application</div>
<core-icon-button icon="refresh"></core-icon-button>
<core-icon-button icon="add"></core-icon-button>
</core-toolbar>
Main content
The last section is left for your content! It can be any type of element. A <div>
is perfectly fine:
<div layout horizontal center-center fit>
<!-- fill with pages -->
</div>
The fit
attribute instructs the main area to take up the full width and height of its parent and layout horizontal center-center
centers that content horizontally and vertically using flexbox.
Creating “views”
Multiple views (or pages) can be created with <core-pages>
or <core-animated-pages>
. Both elements are useful for displaying only one child at a time. The benefit of <core-animated-pages>
is that it provides more defaults and slick transitions between pages.
The demo uses <core-animated-pages>
with the slide-from-right
transition. The first thing to do is import the element definition and the slide-from-right
transition:
<link rel="import" href="components/core-animated-pages/core-animated-pages.html">
<link rel="import" href="components/core-animated-pages/transitions/slide-from-right.html">
then drop in your content:
<div layout horizontal center-center fit> <core-animated-pages selected="0" transitions="slide-from-right"> <section layout vertical center-center> <div>Single</div> </section> <section layout vertical center-center> <div>page</div> </section> ... </core-animated-pages> </div>
Note Right now, <core-animated-pages selected="0">
is hard-coded to select the first page. We’ll make that dynamic later.
By now you should have a basic app, but there’s something subtle to notice. Thanks to Polymer’s layout attributes and the default styles provided by each element, you’ve achieved a responsive app without writing a lick of CSS! Of course, with a little inspiration from the material design color palette, less than 10 CSS rules turns the app into something beautiful.
Using data binding
We have an app, but it’s nothing to write home about. It’s far from DRY. Similar markup is repeated all over the place:
<nav>
<core-menu selected="0">
<paper-item noink>
<core-icon icon="label-outline"></core-icon>
<a href="#one">Single</a>
</paper-item>
<paper-item noink>
<core-icon icon="label-outline"></core-icon>
<a href="#two">page</a>
</paper-item>
<paper-item noink>
<core-icon icon="label-outline"></core-icon>
<a href="#three">app</a>
</paper-item>
...
</core-menu>
</nav>
It’s also not dynamic. When a user selects a menu item the view doesn’t update. Luckily, both of these problems are easily solved with Polymer’s data-binding features.
Auto-binding template
To leverage data binding outside of a <polymer-element>
, Wrap Yo App™ inside an auto-binding <template>
elements:
<body unresolved fullbleed> <template is="auto-binding"> <core-scaffold id="scaffold"> ... </core-scaffold> </template> </body>
Tip An auto-binding <template>
allows us to use {{}}
bindings, expressions, and on-*
declarative event handlers inside the main page.
Simplifying the markup using a data model
You can greatly reduce the amount of markup you write by generating it from a data model. In our case, all the menu items and pages can be rendered with a pair of <template repeat>
:
<core-menu valueattr="hash" selected="{{route}}">
<template repeat="{{page in pages}}">
<paper-item hash="{{page.hash}}" noink>
<core-icon icon="label-outline"></core-icon>
<a href="#{{page.hash}}">{{page.name}}</a>
</paper-item>
</template>
</core-menu>
<core-animated-pages valueattr="hash" selected="{{route}}"
transitions="slide-from-right">
<template repeat="{{page in pages}}">
<section hash="{{page.hash}}" layout vertical center-center>
<div>{{page.name}}</div>
</section>
</template>
</core-animated-pages>
Which is driven by this data model:
<script>
var template = document.querySelector('template[is="auto-binding"]');
template.pages = [
{name: 'Single', hash: 'one'},
{name: 'page', hash: 'two'},
{name: 'app', hash: 'three'},
...
];
</script>
Notice that <core-animated-pages>
and <core-menu>
are linked by binding
their selected
attributes together. Now, when a user clicks on a nav item the view updates accordingly. The valueattr="hash"
tells both elements to use the hash
attribute on each item as the selected value.
<!-- data-bind the menu selection with the page selection -->
<core-menu valueattr="hash" selected="{{route}}">
...
<core-animated-pages valueattr="hash" selected="{{route}}">
URL routing & deep linking
<flatiron-director>
is a web component for routing that wraps the flatiron director JS library. Changing its route
property updates the URL hash to the same value.
We want to persist the last view across page reloads. Once again, data-binding comes in handy. Connecting the director, menu, and page elements put all three in lock-step. When one updates, the others do too.
<flatiron-director route="{{route}}" autoHash></flatiron-director>
...
<core-menu selected="{{route}}">
...
<core-animated-pages selected="{{route}}">
Deep linking - initialize the route
when the template is ready to go:
template.addEventListener('template-bound', function(e) {
// Use URL hash for initial route. Otherwise, use the first page.
this.route = this.route || DEFAULT_ROUTE;
});
Alternative routing libs
If <flatiron-director>
is not your cup of tea, check out <app-router>
or <more-routing>
. Both can do more complex routing (wildcards, HTML5 History API, dynamic content). I personally like <flatiron-director>
because it’s simple and works well with <core-animated-pages>
.
Example: <more-routing>
<more-route-switch>
<template when-route="user">
<header>User {{params.userId}}</header>
<template if="{{ route('user-bio').active }}">
All the details about {{params.userId}}.
</template>
</template>
<template when-route="/about">
It's a routing demo!
<a _href="{{ urlFor('user-bio', {userId: 1}) }}">Read about user 1</a>.
</template>
<template else>
The index.
</template>
</more-route-switch>
Example: <app-router>
<app-route path="/home" import="/pages/home-page.html"></app-route>
<app-route path="/customer/*" import="/pages/customer-page.html"></app-route>
<app-route path="/order/:id" import="/pages/order-page.html"></app-route>
<app-route path="*" import="/pages/not-found-page.html"></app-route>
Keyboard navigation
Keyboard support is not only important for accessibility but it’ll also make your SPA feel…more appy!
<core-a11y-keys>
is a drop-in component for normalizing browser keyboard events and can be used to add keyboard support to your app. Here’s an example:
<core-a11y-keys target="{{parentElement}}"
keys="up down left right space space+shift"
on-keys-pressed="{{keyHandler}}"></core-a11y-keys>
Notes
- The
target
for events is data bound to theparentElement
of our auto-binding template. In this case, that’s<body>
. - The
key
attribute contains a space-separated list of keys to listen for. When one of those combinations is pressed,<core-a11y-keys>
fires akeys-pressed
event and invokes your callback.
The handler for the keys-pressed
event uses <core-animated-pages>
’s selectNext
/selectPrevious
API to advance to the next page or go back to the previous page:
template.keyHandler = function(e, detail, sender) {
var pages = document.querySelector('#pages');
switch (detail.key) {
case 'left':
case 'up':
pages.selectPrevious();
break;
case 'right':
case 'down':
pages.selectNext();
break;
case 'space':
detail.shift ? pages.selectPrevious() : pages.selectNext();
break;
}
};
Loading content on-demand
What if you want to load content dynamically as a user navigates your app? With just a couple of changes, we can support dynamically loaded pages.
First, update the data model to include content URLs:
template.pages = [
{name: 'Intro', hash: 'one', url: '/tutorial/intro.html'},
{name: 'Step 1', hash: 'two', url: '/tutorial/step-1.html'},
...
];
Then add the selectedModel
attribute to the <core-menu>
element to bind it to the currently selected page, and change the menu links to point at page.url
instead of the hash:
<core-menu ... selectedModel="{{selectedPage}}"> ... <paper-item hash="{{page.hash}}" noink> <a _href="{{page.url}}">{{page.name}}</a> </paper-item> ... <core-menu>
The last pieces is to use our good buddy <core-ajax>
for fetching the content:
<core-ajax id="ajax" auto url="{{selectedPage.page.url}}" handleAs="document" on-core-response="{{onResponse}}"> </core-ajax>
You can think of <core-ajax>
as a content controller. Its url
attribute is data-bound to selectedPage.page.url
, which means that whenever a new menu item is selected, an XHR fires off to fetch that page. When core-response
fires, onResponse
injects a portion of the returned document
into its placeholder container:
template.onResponse = function(e, detail, sender) { var article = detail.response.querySelector('scroll-area article'); var pages = document.querySelector('#pages'); this.injectBoundHTML(article.innerHTML, pages.selectedItem.firstElementChild); };
Polish and finishing touches
There are a couple of more final tips and tricks that you can add to polish up your app.
When a menu item is selected, close the app drawer:
<core-menu ... on-core-select="{{menuItemSelected}}">
template.menuItemSelected = function(e, detail, sender) { if (detail.isSelected) { scaffold.closeDrawer(); } };
Render a different icon for the selected nav item:
<paper-item noink>
<core-icon icon="label{{route != page.hash ? '-outline' : ''}}"></core-icon>
</paper-item>
Tapping on a page cycles through the pages:
<core-animated-pages ... on-tap="{{cyclePages}}">
template.cyclePages = function(e, detail, sender) { // If click was on a link, navigate and don't cycle page. if (e.path[0].localName == 'a') { return; } e.shiftKey ? sender.selectPrevious(true) : sender.selectNext(true); };
Conclusion
By now you should understand the basic structure of building a single page app using Polymer and web components. It may feel a bit different than building a tradition app, but ultimately, components make things a lot simpler. When you reuse (core) elements and leverage Polymer’s data-binding features, the amount of CSS/JS you have to wire up is minimal. Writing less code feels good!