This page will take you through the steps you need to do to modify the app and add your own content.
Table of Contents
Folder structure
Your app will be initialized with a bunch of folders and files, that looks like this
my-app
├── images
│ └── favicon.ico
│ └── manifest
│ └── icon-48x48.png
│ └── ...
└── src
└── store.js
└── actions
└── ...
└── reducers
└── ...
└── components
└── ...
└── test
└── unit
└── ...
└── integration
└── ...
├── index.html
├── README.md
├── package.json
├── polymer.json
├── manifest.json
├── service-worker.js
├── sw-precache-config.js
├── wct.conf.json
├── .travis.yml
images/
has your logos and favicons. If you needed to add any other assets to your application, this would be a good place for them.src/
is where all the code lives. It’s broken down in 4 areas:components/
is the directory that contains all the custom elements in the applicationactions/
,reducers/
andstore.js
are Redux specific files and folders. Check out the Redux and state management page for details on that setup.
test/
is the directory with all of your tests. it’s split inunit
tests (that are run across different browsers), andintegration
tests, that just run on headless Chrome to ensure that the end-to-end application runs and is accessible. Check out the application testing page for more information.index.html
is your application’s starting point. It’s where you load your polyfills and the main entry point element.package.json
: thenpm
configuration file, where you specify your dependencies. Make sure you runnpm install
any time you make any changes to this file.polymer.json
: thepolymer cli
configuration file, that specifies how your project should be bundled, what’s included in the service worker, etc. docsmanifest.json
is the PWA metadata file. It contains the name, theme color and logos for your app, that are used whenever a user adds your application to the homescreenservice-worker.js
is a placeholder file for your Service Worker. In each build directory, thepolymer cli
will populate this file with actual contents, but during development it is disabled.sw-precache-config.js
is a configuration file that sets up the precaching behaviour of the Service Worker (such as which files to be precached, the navigation fallback, etc)wct.conf.json
is the web-component-tester configuration file, that specifies the folder to run tests from, etc..travis.yml
sets up the integration testing we run on every commit on Travis.
You can add more app-specific folders if you want, to keep your code organized – for example, you might want to create a src/views/
folder and move all the top-level views in there.
Naming and code conventions
This section covers some background about the naming and coding conventions you will find in this template:
- generally, an element
<sample-element>
will be created in a file calledsrc/components/sample-element.html
, and the class used to register it will be calledSampleElement
. - the elements use a mix of Polymer 3 and
lit-html
via theLitElement
base class. The structure of one of these elements is basically:
import { LitElement, html } from '@polymer/lit-element';
class SampleElement extends LitElement {
// The properties that your element exposes.
static get properties() { return {
publicProperty: Number,
_privateProperty: String /* note the leading underscore */
}};
constructor() {
super();
// Set up the property defaults here
this.publicProperty = 0;
this._privateProperty = '';
}
_render({publicProperty, _privateProperty}) {
// Note the use of the object spread to explicitely
// call out which properties you're using for rendering.
// Anything code that is related to rendering should be done in here.
return html`
<!-- your element's template goes here -->
`;
});
_firstRendered() {
// Any code that relies on render having been called once goes here.
// (for example setting up listeners, etc)
}
...
}
window.customElements.define('sample-element', SampleElement);
- note that private properties are named with a leading underscore (
_foo
instead offoo
). Since JavaScript doesn’t have proper private properties, this in a coding convention that implies this property shouldn’t be used outside of the element itself (so you would never write<sample-element _foo="bar">
)
Customizing the app
Here are some changes you might want to make to your app to personalize it.
Changing the name of your app
By default, your app is called my-app
. If you want to change this (which you obviously will), you’ll want to make changes in a bunch of places:
- config files:
package.json
,polymer.json
andmanifest.json
- in the app:
index.html
, the<title>
, severalmeta
fields, and theappTitle
attribute on the<my-app>
element.
Adding a new page
There are 4 places where the active page is used at any time:
- as a view in the
<main>
element - as a navigation link in the
drawer <nav>
element. This is the side nav that is shown in the small-screen (i.e. mobile) view - as a navigation link in the
toolbar <nav>
element. This is the toolbar that is shown in the wide-screen (i.e. desktop) view - in the code for lazy loading pages. We explicitly list these pages, rather that doing something like
import('./my-'+page+'.js')
, so that the bundler knows these are separate routes, and bundles their dependencies accordingly. ⚠️Don’t change this! :)
To add a new page, you need to add a new entry in each of these places. Note that if you only want to add an external link or button in the toolbar, then you can skip adding anything to the <main>
element.
Create a new page
First, let’s create a new element, that will represent the new view for the page. The easiest way to do this is to copy the <my-view404>
element, since that’s a good and basic starting point:
- Copy that file, and rename it to
my-view4.js
. We’re going to assume the element’s name is alsomy-view4
, but if you want to use a name that makes more sense (likeabout-page
or something), you can totally use that – just make sure you are consistent! - In this new file, rename the class to
MyView404
toMyView4
(in 2 places), and the element’s name tomy-view4
. When you’re done, it should look like this:
import { html } from '@polymer/lit-element/lit-element.js';
import { PageViewElement } from './page-view-element.js';
import { SharedStyles } from './shared-styles.js';
class MyView4 extends PageViewElement {
_render(props) {
return html`
${SharedStyles}
<section>
<h2>Oops! You hit a 404</h2>
<p>The page you're looking for doesn't seem to exist. Head back
<a href="/">home</a> and try again?
</p>
</section>
`
}
}
window.customElements.define('my-view4', MyView4);
(🔎This page extends PageViewElement
rather than LitElement
as an optimization; for more details on that, check out the conditional rendering section)
Adding the page to the application
Great! Now we that we have our new element, we need to add it to the application!
First, add it to each of the list of nav links. In the toolbar (the wide-screen view) add:
<nav class="toolbar-list">
...
<a selected?="${_page === 'view4'}" href="/view4">New View!</a>
</nav>
Similarly, we can add it to the list of nav links in the drawer:
<nav class="drawer-list">
...
<a selected?="${_page === 'view4'}" href="$/view4">New View!</a>
</nav>
And in the main content itself:
<main class="main-content">
...
<my-view4 class="page" active?="${_page === 'view4'}"></my-view4>
</main>
Note that in all of these code snippets, the selected
attribute is used to highlight the active page, and the active
attribute is also used to ensure that only the active page is actually rendered.
Finally, we need to lazy load this page. Without this, the links will appear, but they won’t be able to navigate to your new page, since my-view4
will be undefined (we haven’t imported its source code anywhere). In the loadPage
action creator, add a new case
statement:
switch(this.page) {
...
case 'view4':
await import('../components/my-view4.js');
break;
}
Don’t worry if you don’t know what an action creator is yet. You can find a complete explanation of how it fits into the state management story in the Redux page.
That’s it! Now, if you refresh the page, you should be able to see the new link and page. Don’t forget to re-build your application before you deploy to production (or test that build), since this new page needs to be added to the output.
Adding the page to the push manifest
To take advantage of HTTP/2 server push, you need to specify what scripts are needed for the new page. Add a new entry to push-manifest.json
:
{
"/view4": {
"src/components/my-app.js": {
"type": "script",
"weight": 1
},
"src/components/my-view4.js": {
"type": "script",
"weight": 1
},
"node_modules/@webcomponents/webcomponentsjs/webcomponents-loader.js": {
"type": "script",
"weight": 1
}
},
/* other entries */
}
Using icons
You can inline an <svg>
directly where you need it in the page, but if there’s any reusable icons you’d
like to define once and use in several places, my-icons.js
is a good spot for that. To add a new icon, you
can just add a new line to that file:
export const closeIcon = html`<svg>...</svg>`
Then, you can import it and use it as a template literal in an element’s _render()
method:
import { closeIcon } from './my-icons.js';
_render(props) {
return html`
<button title="close">${closeIcon}</button>
`;
}
Sharing styles
Similarly, shared styles are also just exported template literals. If you take a look at shared-styles.js
, it
exports a <style>
node template, that is then inlined in an element’s _render()
method:
import { SharedStyles } from './shared-styles.js';
_render(props) {
return html`
${SharedStyles}
<div>...</div>
`;
}
Fonts
The app doesn’t use any web fonts for the content copy, but does use a Google font for the app title. Be careful not too load too many fonts, however: aside from increasing the download size of your first page, web fonts also slow down the performance of an app, and cause flashes of unstyled content.
But I don’t want to use Redux
The pwa-starter-kit
is supposed to be the well-lit path to building a fairly complex PWA, but it should in no way feel restrictive. If you know what you’re doing, and don’t want to use Redux to manage your application’s state, that’s totally fine! We’ve created a separate template, template-no-redux
, that has the same UI and PWA elements as the main template, but does not have Redux.
Instead, it uses a unidirectional data flow approach: some elements are in charge of maintaining the state for their section of the application, and they pass that data down to children elements. In response, when the children elements need to update the state, they fire an event.
Advanced topics
Responsive layout
By default, the pwa-starter-kit
comes with a responsive layout. At 460px
, the application switches from a wide, desktop view to a small, mobile one. You can change this value if you want the mobile layout to apply at a different size.
For a different kind of responsive layout, the template-responsive-drawer-layout
template displays a persistent app-drawer, inline with the content on wide screens (and uses the same small-screen drawer as the main template).
Changing the wide screen styles
The wide screen styles are controlled in CSS by a media-query. In that block you can add any selectors that would only apply when the window viewport’s width is at least 460px
; you can change this pixel value if you want to change the size at which these styles get applied (or, can add a separate style if you want to have several breakpoints).
Changing narrow screen styles
The rest of the styles in my-app
are outside of the media-query, and thus are either general styles (if they’re not overwritten by the media-query styles), or narrow-screen specific, like this one (in this example, the <nav class="toolbar-list">
is hidden in the narrow screen view, and visible in the wide screen view)
Responsive styles in JavaScript
If you want to run specific JavaScript code when the size changes from a wide to narrow screen (for example, to make the drawer persistent, etc), you can use the installMediaQueryWatcher
helper from pwa-helpers
. When you set it up, you can specify the callback that is ran whenever the media query matches.
Conditionally rendering views
Which view is visible at a given time is controlled through an active
attribute, that is set if the name of the page matches the location, and is then used for styling:
<style>
.main-content .page {
display: none;
}
.main-content .page[active] {
display: block;
}
</style>
<main class="main-content">
<my-view1 class="page" active?="${page === 'view1'}"></my-view1>
<my-view2 class="page" active?="${page === 'view2'}"></my-view2>
...
</main>
However, just because a particular view isn’t visible doesn’t mean it’s “inactive” – its JavaScript can still run. In particular, if your application is using Redux, and the view is connected (like my-view2
for example), then it will get notified any time the Redux store changes, which could trigger _render()
to be called. Most of the time this is probably not what you want – a hidden view shouldn’t be updating itself until it’s actually visible on screen. Apart from being inefficient (you’re doing work that nobody is looking at), you could run into really weird side effects: if a view’s _render()
function also updates the title of the application, for example, the title may end up being set incorrectly by one of these inactive views, just because it was the last view to set it.
To get around that, the views inherit from a PageViewElement
base class, rather than LitElement
directly. This base class checks whether the active
attribute is set on the host (the same attribute we use for styling), and calls _render()
only if it is set.
If this isn’t the behaviour you want, and you want hidden pages to update behind the scenes, then all you have to do is change the view’s base class back to LitElement
(i.e. changing this line). Just look out for those side effects!
Routing
The app uses a very basic router, that listens to changes to the window.location
. You install the router by passing it a callback, which is a function that will be called any time the location changes:
installRouter((location) => this._locationChanged(location));
Then, whenever a link is clicked (or the user navigates back to a page), this._locationChanged
is called with the new location. You can check the Redux page to see how this location is stored in the Redux store.
Sometimes you might need to update this location (and the Redux store) imperatively – for example if you have custom code for link hijacking, or you’re managing page navigations in a custom way. In that case, you can manually update the browser’s history state yourself, and then call the this._locationChanged
method manually (thus simulating an action from the router):
// This function would get called whenever you want
// to manually manage the location.
onArticleLinkClick(page) {
const newLocation = `/article/${page}`
window.history.pushState({}, '', newLocation);
this._updateLocation(newLocation);
};
SEO
We’ve added a starting point for adding rich social graph content to each pages, both using the Open Graph protocol (used on Facebook, Slack etc) and Twitter cards.
This is done in two places:
- statically, in the
index.html
. These are used by the homepage, and represent any of the common metadata across all pages (for example, if you don’t have a page specific description or image, etc). - automatically, after you change pages, in
my-app.js
, using theupdateMetadata
helper frompwa-helpers
. By default, we update the url and the title of each page, but there are multiple ways in which you can add page-specific content that depend on your apps.
A different approach is to update this metadata differently, depending on what page you are. For example, the Books doesn’t update the metadata in the main top-level element, but on specific sub-pages. It uses the image thumbnail of a book only on the detail pages, and adds the search query on the explore page.
If you want to test how your site is viewed by Googlebot, Sam Li has a great article on gotchas to look out for – in particular, the testing section covers a couple tools you can use, such as Fetch as Google and Mobile-Friendly Test.
Fetching data
If you want to fetch data from an API or a different server, we recommend dispatching an action creator from a component, and making that fetch asynchronously in a Redux action. For example, the Flash Cards sample app dispatches a loadAll
action creator when the main element boots up; it is that action creator that then does the actual fetch of the file and sends it back to the main component by adding the data to the state in a reducer.
A similar approach is taken in the Hacker News app where an element dispatches an action creator, and it’s that action creator that actually fetches the data from the HN api.
Responding to network state changes
You might want to change your UI as a response to the network state changing (i.e. going from offline to online).
Using the installOfflineWatcher
helper from pwa-helpers
, we’ve added a callback that will be called any time we go online or offline. In particular, we’ve added a snackbar that gets shown; you can configure its contents and style in snack-bar.js
. Note that the snackbar is shown as a result of a Redux action creator being dispatched, and its duration can be configured there.
Rather than just using it as an fyi, you can use the offline status to display conditional UI in your application. For example, the Books sample app displays an offline view rather than the details view when the application is offline.
State management
There are many different ways in which you can manage your application’s state, and choosing the right one depends a lot on the size of your team and application. For simple applications, a uni-directional data flow pattern might be enough (the top level, <my-app>
element could be in charge of being the source of state truth, and it could pass it down to each of the elements, as needed); if that’s what you’re looking for, check out the template-no-redux
branch.
Another popular approach is Redux, which keeps the state in a store outside of the app, and passes immutable copies to each element. To see how that is setup up, check out the Redux and state management section for an explainer, and more details.
Theming
This section is useful both if you want to change the default colours of the app, or if you want to let your users be able to switch between different themes.
Changing the default colours
For ease of theming, we’ve defined all of the colours the application uses as CSS custom properties, in the <my-app>
element. Custom properties are variables that can be reused throughout your CSS. For example, to change the application header to be white text on a lavender background, then you need to update the following properties:
--app-header-background-color: lavender;
--app-header-text-color: black;
And similarly for the other UI elements in the application.
Switching themes
Re-theming the entire app basically involves updating the custom properties that are used throughout the app. If you just want to personalize the default template with your own theme, all you have to do is change the values of the app’s custom properties.
If you want to be able to switch between two different themes in the app (for example between a “light” and “dark” theme), you could just add a class (for example, dark-theme
) to the my-app
element for the new theme, and style that separately. This would end up looking similar to this:
:host {
/* This is the default, light theme */
--app-primary-color: red;
--app-secondary-color: black;
--app-text-color: var(--app-secondary-color);
--app-header-background-color: white;
--app-header-text-color: var(--app-text-color);
...
}
:host.dark-theme {
/* This is the dark theme */
--app-primary-color: yellow;
--app-secondary-color: white;
--app-text-color: var(--app-secondary-color);
--app-header-background-color: black;
--app-header-text-color: var(--app-text-color);
...
}
You control when this class is added; this could be when a “use dark theme” button is clicked, or based on a hash parameter in the location, or the time of day, etc.
Next steps
Now that you’re done configuring your application, check out the next steps:
- Testing the performance of your app to ensure your users have a fast experience
- General testing your app to make sure new changes don’t accidentally cause regressions
- Building and deploying to production