Motivation
I decided to give my Hacker News reading experience a facelift.
First and foremost, I wanted Dark Mode!
Second, I wanted to be able to "install" it on my iPhone's homescreen, so that it runs in its own process, and not in Safari. (Dev.to does this natively, kudos!)
I also wanted to build a project over break that would let me explore new web standards. I wanted to commit to using the latest tools of the native web platform, so I wouldn't use any JS libraries or create a build process. I also wouldn't worry about any browsers other than the ones I use every day -- latest Safari and Chromium.
Before I started, I also got the idea to make it a little more functional for myself, so that it loads to the top comment along with the headline.
Finally, I wanted to timebox it to 24 hours.
Step #1: Loading Data
This was the easy part. The Hacker News API has an endpoint that provides JSON data of the stories. No authorization, no setup, just load the data.
Since I wasn't limited by browser support, I could safely use fetch
, Promises, and async
/await
:
const storyIDs = await fetch(`https://hacker-news.firebaseio.com/v0/topstories.json`).then(res => res.json())
const stories = await Promise.all(storyIDs.slice(0, 25).map(id => fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`).then(res => res.json())))
Step #2: Templating and Dynamic Data
Each of the loaded stories would be rendered as an instance of a web component.
There are basically 3 types of data to consider when you use a web component:
- Named slots
- Custom properties
- Custom attributes
I ended up not having a need for custom attributes.
Let's start by looking at the template for a top-story
element:
<template>
<article class="top-story">
<span class="top-story-submitter">
<slot name="by"></slot>
</span>
<div class="top-story-content">
<a class="top-story-main" href="">
<h3 class="top-story-headline">
<slot name="title"></slot>
</h3>
</a>
<slot name="top-comment"></slot>
</div>
</article>
</template>
I'm using named slots where I want the dynamic content to go. This will be on the Shadow DOM side.
Anything in the Light DOM side with a matching slot
attribute will be injected into the rendered template.
So for the dynamic data, I needed to convert each JSON data property received from the API into an HTML element with a slot
attribute. I'm adding the JSON data to the web component as custom properties, then letting setting those properties trigger the creation of the elements with a slot
attribute.
stories.forEach(story => {
if (story) { // can be null
const element = window.document.createElement('top-story')
window.document.body.append(element)
Object.assign(element, story)
}
})
Object.assign
here is setting these directly on the element, so we can set those up to be custom properties that react to changes.
In the web component, I have a helper function to do the property conversion to slots, and I have a setter for each of the properties:
window.customElements.define('top-story', class extends HTMLElement {
constructor() {
super()
}
setSlot(slot, value) {
if (!this.querySelector(`[slot="${slot}"]`)) {
const element = window.document.createElement('data')
element.setAttribute('slot', slot)
this.append(element)
}
this.querySelector(`[slot="${slot}"]`).innerHTML = value
}
set text(value) {
this.setSlot('text', value)
}
...
}
Now, if I change the data on the component, the slot will also update on the Light DOM side, which will update in place in the rendered Shadow DOM.
I can also use the setters to do other kinds of work. I want to embed another web component for the Top Comment inside this one, so I won't use my setSlot
helper function. Instead, in the setter, I set up that component the same way I set up this one. This is also where I updated the href
attributes on the links.
Step #3: Code Splitting / Imports
Typically I use webpack for converting my projects to ES5 and concatenating into a single JS file.
Here I'm using native JS imports to add the split-up files. Add that to the fact that the base markup is in its own web component, and my HTML file ends up being pretty light:
<body>
<app-screen></app-screen>
<link rel="stylesheet" href="./styles.css">
<script type="module">
import './imports/fetcher.js'
import './imports/AppScreenTemplate.js'
import './imports/AppScreen.js'
import './imports/TopCommentTemplate.js'
import './imports/TopComment.js'
import './imports/TopStoryTemplate.js'
import './imports/TopStory.js'
</script>
</body>
Step #4: Dark Mode
Although I always use Dark Mode, I wanted to use the native CSS media query that detects Dark Mode in the system settings, in case someone else was used to Light Mode instead:
@media (prefers-color-scheme: dark) {
body {
background: black;
color: white;
}
}
Step #5: PWA Installation
One of the most important aspects of all this was to make Hacker News run like a native app, in its own window and not in Safari. That way my scroll state would be preserved.
This is actually pretty simple for iOS:
<meta name="apple-mobile-web-app-capable" content="yes" />
To make this more compliant with other browsers, including Chromium Edge, which I have been using, I also added a manifest.json file:
{
"name": "Hacker News PWA",
"short_name": "HN",
"theme_color": "#CD00D8",
"background_color": "#000000",
"display": "standalone",
"orientation": "portrait",
"scope": "/",
"start_url": "/",
"icons": [{
"src": "/icons/icon-512x512.png",
"type" : "image/png",
"sizes": "512x512"
}]
}
Challenge #1: Dates!
I ended up removing all dates from the project for now. I'm used to using a library such as moment.js or date-fns, and the native functions would sometimes show undefined or have other problems! I think for the final product, if I continue with it, I will pull in one of those libraries.
Challenge #2: Time Constraints
I had planned on having the comments (and possibly even the story if iframe embed is supported) show up in a modal drawer that overlays the rest of the content. This might still happen, but it's outside the 24-hour timebox.
It also isn't quite a full-fledged PWA with service workers. I need to do some work on automatically refreshing content.
Conclusion
I had a great time working on this, and I have started using it whenever I want to check Hacker News. You might enjoy it too.
Install it as an "Add to Homescreen" app from Safari:
http://hn-pwa-1.firebaseapp.com/
Contribute:
https://github.com/michaelcpuckett/hn-pwa-1
Final Result:
Top comments (4)
what about the service worker? is it not required or you didn't include it in this article? I am new to PWA world, thus, curious about it.
There's no service workers... yet. So maybe it doesn't count as a PWA. Sorry if that's misleading! I'm new to PWAs as well.
My goal was to get a fullscreen web app with its own window/process, which for this case doesn't require a service worker.
I would like to do background refresh next. I believe that would require a service worker.
I was also working on a PWA in the previous days. Here's the post related to it:
Inspirofy
Ankit Beniwal ・ Jan 3 ・ 1 min read
Check it out: Live or source code
Thanks for posting this excellent article Michael.