DEV Community

Michael Puckett
Michael Puckett

Posted on

How I Built a Dark Mode PWA without JS Libraries in 24 Hours

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:

Final Result

Top comments (4)

Collapse
 
ankitbeniwal profile image
Ankit Beniwal • Edited

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.

Collapse
 
mpuckett profile image
Michael Puckett • Edited

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.

Collapse
 
ankitbeniwal profile image
Ankit Beniwal

I was also working on a PWA in the previous days. Here's the post related to it:

Check it out: Live or source code

Collapse
 
jwp profile image
JWP

Thanks for posting this excellent article Michael.