DEV Community

Michael Puckett
Michael Puckett

Posted on

11 4

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

SurveyJS custom survey software

JavaScript UI Libraries for Surveys and Forms

SurveyJS lets you build a JSON-based form management system that integrates with any backend, giving you full control over your data and no user limits. Includes support for custom question types, skip logic, integrated CCS editor, PDF export, real-time analytics & more.

Learn more

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.

Billboard image

The Next Generation Developer Platform

Coherence is the first Platform-as-a-Service you can control. Unlike "black-box" platforms that are opinionated about the infra you can deploy, Coherence is powered by CNC, the open-source IaC framework, which offers limitless customization.

Learn more

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay