Templating with TinyBase, Tailwind, and RippleUI

parenttobias

Toby Parent

Posted on February 20, 2023

Templating with TinyBase, Tailwind, and RippleUI

In the last section, First Steps With TinyBase, we built the data models we would need to support the front end of a Todo app. We will come back around to those. At this point, it might make sense to set up a "template view", one that sets up our sections/containers.

For ease of use, we'll continue with Vite and TinyBase, but we'll also add in two more packages: Tailwind CSS and RippleUI. For those who haven't used it yet, Tailwind CSS is a great utility package that will allow us to style directly in our HTML, by applying classes to elements. And RippleUI is a component framework that builds on Tailwind. So together, they're a powerful team.

In addition to building the basic site itself, we'll also work with some features of TinyBase that will be very useful both here and going forward. We will set up some values in TinyBase, rather than the tables we used in the last section. We will set up persistence, allowing the TinyBase store to save/load localStorage as needed.
And finally, we'll explore listeners and reactivity in TinyBase, allowing us to listen to change events on a store and respond accordingly.

Some Setup Stuff

First, we'll add in the packages we'll need. Let's begin with an entirely new Vite project:

npm create vite@latest app-skeleton --template vanilla 
// OR
yarn create vite app-skeleton --template vanilla
Enter fullscreen mode Exit fullscreen mode

Going forward, I'll be using yarn, but take as given that you can use npm if you prefer. Next, we'll install the packages so far and add in TinyBase:

cd app-skeleton
npm install tinybase
Enter fullscreen mode Exit fullscreen mode

That installs the existing packages, as well as adding TinyBase. Next, we will get Tailwind CSS and its dependencies (taken from https://tailwindcss.com/docs/guides/vite):

yarn add tailwindcss postcss autoprefixer -D
Enter fullscreen mode Exit fullscreen mode

WIth the modules added, we can create the tailwind.config.cjs and postcss.config.cjs:

npx tailwindcss init -p
Enter fullscreen mode Exit fullscreen mode

That will set up two files for us in the root of our Vite project. We'll need to tell Tailwind's JIT parser where to work, so we'll edit the tailwind.config.cjs first:

// tailwind.config.cjs

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    // add in these two lines
    "./index.html",
    "./src/**/*.{js,ts,jsx,tsx}",
  ], 
  theme: {
    extend: {},
  }, 
  plugins: [], 
}
Enter fullscreen mode Exit fullscreen mode

We don't need to add the ts,jsx,tsx options at this point, but if some of you are working in Typescript, you might want them.

Next, we need to edit the postcss.config.cjs, just to be sure it's set up properly. If you left the -p option off the npx tailwindcss init, you won't have that file, so you'd just need to create it yourself. Either way, make sure it contains:

// postcss.config.js

module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  }
}
Enter fullscreen mode Exit fullscreen mode

Finally, we need to tell Vite to go ahead and bring in the Tailwind classes. In our style.css, we place this at the top:

@tailwind base;
@tailwind components;
@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

At that point, we have tailwind all set up and ready to go. Let's also add in RippleUI at this point:

yarn add rippleui
Enter fullscreen mode Exit fullscreen mode

With the package installed, we need Tailwind to be made aware of it. So we go back to the tailwind.config.cjs and add a plugin:

// tailwind.config.cjs

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./index.html",
    "./src/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  // EDIT THIS LINE
  plugins: [require('rippleui')],
}
Enter fullscreen mode Exit fullscreen mode

With that done, we should be good to go. At this point, we can start the dev server and begin playing!

npm run dev
// OR
yarn dev
Enter fullscreen mode Exit fullscreen mode

One more thing to include, simply to create icons quickly and easily: open up the index.html, and we'll add a link to Bootstrap Icons via CDN:

<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.3/font/bootstrap-icons.css'>
Enter fullscreen mode Exit fullscreen mode

We could as easily have added this via npm or yarn, do what works for you.

A Handy Utility Snippet

We'll need an easy way of converting strings to DOM trees, as we will be wanting to construct and style some pretty complex DOM nodes. David Walsh writes about a very useful method for just this (https://davidwalsh.name/convert-html-stings-dom-nodes), which we'll leverage into a toHtml method:

// src/util/toHtml.js

const toHtml = (str)=>document.createRange()
  .createContextualFragment(
    str.trim()
  ).firstChild;

export default toHtml;
Enter fullscreen mode Exit fullscreen mode

This will take in an HTML string and convert it into a DOM tree for us. This can become powerful, as we aren't obligated to provide static strings - we can use template literals as we like, and dynamically create our DOM!

const getTimeEl = ()=> toHtml(`
<span class='time'>${new Date().toTimeString()}</span>
`)
Enter fullscreen mode Exit fullscreen mode

That function will dynamically update the template literal, and create a new <span> containing the current time for us.

I'll be using the toHtml function quite a lot, for building and populating templates.

A Header Template

// src/templates/header.js
import toHtml from "../util/toHtml";

const Header = toHtml(`
<header >
  <h1>App Title</h1>
  <div class='header-controls'>
    <div class='light-switch'>
      <i class="bi-brightness-high"></i>
    </div>
  </div>
</header>`);

export default Header;
Enter fullscreen mode Exit fullscreen mode

So the HTML here is pretty straightforward, and at this point, we have three classes included: header-controls, light-switch and bi-brightness-hight. The last one is a Bootstrap Icons class, showing a circle with rays. The first two are ones we will hook into later - the .light-switch will be used for toggling between light and dark mode.

But we'll want to add in some Tailwind classes to pretty things up:

import toHtml from "../util/toHtml";

const Header = toHtml(`
<header class='flex justify-between px-3 py-2'>
  <h1 class='inline-block text-3xl font-black text-indigo-800 p-6'>App Title</h1>
  <div class='header-controls'>
    <div class='light-switch text-3xl font-black text-secondary'>
      <i class="bi-brightness-high"></i>
    </div>
  </div>
</header>`);

export default Header;
Enter fullscreen mode Exit fullscreen mode

And finally, I would like that .light-switch to display as a button. Things like this are why I've added RippleUI:

    <div class='light-switch btn btn-outline-secondary text-3xl font-black text-secondary'>
      <i class="bi-brightness-high"></i>
    </div>
Enter fullscreen mode Exit fullscreen mode

So I added the btn btn-outline-secondary classes to the .light-switch, and the RippleUI button styles will be applied.

... And a Layout Template

For the layout, I would like to have a sidebar and a main content area. Also, simply because we can, we'll make that sidebar collapsible.

import toHtml from "../util/toHtml"

const Layout = toHtml(`
<main class='container flex'>
  <div>
    <input type='checkbox' id='drawer-left' class='drawer-toggle' />
    <label for='drawer-left' class='btn btn-secondary'>
      <span class='bi bi-list font-black text-xl'></span>
    </label>
    <label class='overlay' for='drawer-left'></label>
    <div class='drawer'>
      <div class='drawer-content'>
        <label class='absolute top-4 right-4' for='drawer-left'>
          <span class='bi bi-x-circle text-secondary font-black text-xl'></span>
        </label>
        <p>Sidebar Content</p>
      </div>
    </div>
  </div>
  <div class='main-content'></div>
</main>`);

export default Layout;
Enter fullscreen mode Exit fullscreen mode

In the <main>, we have two divs: one for the sidebar, and one for the remaining content. The sidebar is a bit more complex, as it does have some moving parts - all of which are being managed and defined from RippleUI. The class="drawer-toggle" applied to the input hides it, but also sets up the toggle functionality for the sidebar as a drawer component.

Now, if we revisit the index.html, we have something like this:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>TobyPlaysTheUke :: Template App</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.3/font/bootstrap-icons.css">
  </head>
  <body>
    <div id="app" class="w-full h-full"></div>
    <script type="module" src="/main.js"></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

I've added the class="w-full h-full" to the app container, simply so that it is forced to the full screen size.

Composing the View

In our main.js, we will import those templates, and we'll drop them in:

// main.js

import "./style.css"

import Header from './src/templates/header';
import Layout from './src/templates/layer';

document.querySelector("#app").append(Header, Layout);

Enter fullscreen mode Exit fullscreen mode

And, at this point, the page should be set. The hamburger menu opens an empty sidebar, the header content displays as we like, but we haven't set up the responsivity yet - we can't click the light switch. Let's work on that next!

Getting Back to TinyBase

There may be other site configuration we want to do later, so we will set up a second TinyBase data store, leaving the one from the last lesson alone. It will still be set up as a service, like so:

// src/services/config.js
import { createStore } from 'tinybase';

const configStore = createStore();

export default configStore;
Enter fullscreen mode Exit fullscreen mode

Now, we want to not only switch between dark mode and light, but we want that choice to persist. Fortunately, TinyBase comes with a great localStorage interface that we'll consume, createLocalPersister:

// src/services/config.js
import { createStore, createLocalPersister } from 'tinybase';

const configStore = createStore();
const configPersister = createLocalPersister(configStore, 'siteConfig')

export default configStore;
Enter fullscreen mode Exit fullscreen mode

createLocalPersister takes two parameters: the store we want to persist, and a key for localStorage . With that, our configPersister is ready to run.

As I mentioned, later we might want to add more user-configurable values (color preferences, position of components, whatever). At this point, we only have one property we'll want to deal with, which I'll call colorMode. In order to make this updateable later, we can create a defaultConfig.json file. Doing that, we can have a set of defaults if the user hasn't specified one:

// src/defaultConfig.json
{
  "colorMode": "light"
}
Enter fullscreen mode Exit fullscreen mode

We'll default to light mode for now. But how do we tell our configStore to use that? Further, we don't always want to use that - we only want to use that if there isn't a value in the localStorage yet!

Fortunately, TinyBase has us covered. We can tell the configPersister to start saving/loading automatically any time a value is changed, and we can provide a default for the autoload! Let's see how that works:

import {createStore, createLocalPersister} from 'tinybase';
import * as initialConfig from '../defaultConfig.json';

const configStore = createStore();
const configPersister = createLocalPersister(configStore, 'siteConfig');

configPersister.startAutoLoad( {}, initialConfig );
configPersister.startAutoSave();

export default configStore;
Enter fullscreen mode Exit fullscreen mode

So we call configPersister.startAutoLoad(), passing in two parameters. The first would define the tables we'd want to default to, and the second is the values object we want as our defaults. We aren't using tables here at all, so I've passed in an empty object, but for our values, I pulled the defaultConfig.json into a variable, initialConfig.

That will check localStorage for the "siteConfig" key and, if it exists, populate the configStore from that. If localStorage doesn't have that key, it will use the initialConfig to set the store's values for us.

And then we call configPersister.startAutoSave() - and this will automatically write our store back into localStorage any time anything changes. That's it for the configStore, let's jump back to main.js and see how we might consume this!

Updating the Color Theme

The way Tailwind and RippleUI handle dark mode, we want to set a data-theme attribute and color-scheme style on the <html> tag. But what do we want to set them to? Well, we have the configStore defined for exactly this purpose:

// main.js
import "./style.css";
import configStore from './src/services/config';

import Header from './src/templates/header';
import Layout from './src/templates/layout';

const setColor = () =>{
  document.documentElement.dataset.theme = configStore.getValue('colorMode');
  document.documentElement.style.colorScheme = configStore.getValue('colorMode');
}

setColor();

document.querySelector('#app').append(Header, Layout);
Enter fullscreen mode Exit fullscreen mode

So we're setting the documentElement.dataset.theme, which is the <html> tag's "data-theme" attribute, to the current value of configStore.getValue('colorMode') - which by default is "light". And we do the same to the inline style element. If you examine the <html> element in the Dev Tools > Element Inspector, you'll see those attributes now set.

Wiring the Light Switch

Now, in the Header, we included a ".light-switch" element. When that is clicked, we want to update the configStore, flipping it from "light" to "dark" or vice versa.

// main.js

const setColor = () =>{
  document.documentElement.dataset.theme = configStore.getValue('colorMode');
  document.documentElement.style.colorScheme = configStore.getValue('colorMode');
}

setColor();

document.querySelector('#app').append(Header, Layout);

Header.querySelector(".light-switch").addEventListener("click", (e)=>{
  configStore.setValue(
    'colorMode', 
    configStore.getValue('colorMode')==='dark' ?
      'light' :
      'dark'
  );
  e.currentTarget.querySelector("i").classList.toggle("bi-brightness-high");
  e.currentTarget.querySelector("i").classList.toggle("bi-moon-stars");
})
Enter fullscreen mode Exit fullscreen mode

so the configStore.setValue() is being changed based on its current value. If that value is "dark", we set it to "light". Otherwise, we set it to "dark". At the same we can toggle the light mode and dark mode icons, flipping the icon to match the theme.

But we've flipped the configStore, and we've switched the button - we haven't yet changed the page itself. This is where we explore reactivity in TinyBase.

Responding to Changes

We've got everything set, and we could have simply called setColor from within the event listener for the switch - but what if we wanted to add other functionality, other side effects, when the colorMode changes? We would either have to go back to the lightswitch method and keep adding more mess there, or we need to be able to observe and respond to changes.

Fortunately (again), TinyBase comes to the rescue with a full suite of responsivity methods! Here, we only need one: we're listening to one particular value, "colorMode", and we want to respond any time that one value is altered.

Looking at https://tinybase.org/guides/the-basics/listening-to-stores/, we can see that there are many ways we could listen to a store. We can listen for changes to any one or all values or value id (think the "key" for that value), as well as any or all tables, table rows, or cells, or any of their id values. If any of that changes, we can listen to whatever level of granularity we might need.

For this, we can get away with simplicity: we are only responding based on the change of a particular value: "colorMode". So to listen to that, we add this last line:

configStore.addValueListener("colorMode", setColor)
Enter fullscreen mode Exit fullscreen mode

That will call the setColor method each time the colorMode value changes in the store, toggling the page itself between light mode and dark.

To Recap

Again, this is a simple reactive use, we could do considerably more with it (and in the next lesson, we will get reactive with tabular data). But we've set up a pretty solid skeleton project here, and one that I have published as my repo template. In this, TinyBase may seem like a small part, but the significance of not having to deal with localStorage, with having default values, and with reactive listeners is significant.

The repo, which is also my template repo, is available: https://codeberg.org/tobyPlaysTheUke/vite-tailwindcss-rippleui-vanilla-template/src/branch/main

💖 💪 🙅 🚩
parenttobias
Toby Parent

Posted on February 20, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related