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
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
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
WIth the modules added, we can create the tailwind.config.cjs
and postcss.config.cjs
:
npx tailwindcss init -p
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: [],
}
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: {},
}
}
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;
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
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')],
}
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
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'>
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;
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>
`)
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;
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;
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>
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;
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>
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);
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;
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;
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"
}
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;
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);
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");
})
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)
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
Top comments (0)