DEV Community

Cover image for From Concept to Reality: My process of building Cardboard
Keff
Keff

Posted on • Updated on

From Concept to Reality: My process of building Cardboard

I recently wrote hobo-js. It's a little library for generating HTML from JavaScript with a simple API. While writing it, I (of course) experimented with making it a "framework". But it did not fit in with my vision. I also wrote an article about Hobo if you're interested.

So now that Hobo is "done", I want to experiment with the "framework" idea. And try to explain my process when writing code. Don't worry. I know the word "framework" can be scary nowadays, so let's think of it as: making Hobo work runtime! Better, phew, close one...

What do I mean by working at runtime? Well, at the moment, Hobo renders html as a string. It's up to the user to do something with it. So what if Hobo could work in the browser, and instead of just outputting a string, it interacts with the DOM directly?

I've decided that instead of trying to make Hobo work runtime, I'm going to make it a separate project, called Cardboard.

Why the name? Well, to keep in the brand of Hobo... (I don't remember why I decided to call it Hobo, but I remember I had a good reason xD)
I've decided to call this project Cardboard, because. Also, cardboard is quite versatile and light.

The idea for this article is to make Cardboard, and build a to-do list app written using Cardboard!

TLDR; The article is a bit lengthy. If you just want to check out the end result, feel free to jump to the to-do app or check out Cardboard over on GitHub.

Index

The idea

My idea is to make Cardboards API as similar to Hobos as possible, this way if you learn one you can easily use the other. And, I might be biased, but I like Hobos API.

The main concept will be that we can create any Tag we want (i.e. a div, a button, etc...). Whenever we create a tag it will be added to the DOM.

Something like this:

div(p("Hello world"));
Enter fullscreen mode Exit fullscreen mode

Equates to:

<div><p>Hello world</p></div>

The above code will add a div, and a p with the text "Hello world" to the DOM. Instead of creating a tag, getting the parent, and then pushing it as a child, we just create it and it will be directly added to the dom. I've decided to do it in this way to make it more similar to HTML. In HTML if you add a div to the document it will be displayed when viewing the page, I want the same for Cardboard.

Cardboard should have control over basic things such as "styles", "attributes", "events", etc...

button('click me')
    .setAttr('disabled', '')
    .setStyle('color', 'red')
    .click(() => console.log("I've been clicked!!!!"));
Enter fullscreen mode Exit fullscreen mode

Another thing I want is to add some way of reacting to state changes. Something like this:

const counterState = state({ count: 0 });
counterState.count.changed((newCount) => console.log("`count` changed to -> " + newCount));
Enter fullscreen mode Exit fullscreen mode

Additionally, I want to allow tags to consume states:

const counterState = state({ count: 0 });
p("Clicked 0 times").consume(
    counterState.count, 
    (self, count) => self.text(`Clicked ${count} times`)
);
counterState.count++;
Enter fullscreen mode Exit fullscreen mode

Whenever count changes, it will call the callback provided inside consume. By doing this, tags will be able to react to state changes. Like in the example above, when count changes the text of the tag will update based on the new state.

One thing that could be improved is that it's a bit redundant to have the text passed to p and also then set in consume. I would like to just add it to the consumer in this scenario. This can be done by calling the consumer once when we call consume. It will end up like this:

const counterState = state({ count: 0 });
p().consume(
    counterState.count, 
    (self, count) => self.text(`Clicked ${count} times`)
);
counterState.count++;
Enter fullscreen mode Exit fullscreen mode

A little bit more efficient.


I would also like to have a variety of helper methods to speed up writing Cardboard apps.
For example, getting the value of an input, clearing an input, disabling and enabling elements, etc...

Okay, now that we've laid down the basic idea for Cardboard, let's start making it.


Basic concept

The basic concept for cardboard will be CTag, a class that represents an element on the page. It will hold the reference to the element and will expose a list of methods for interacting with it.

CTag will allow us to, create elements by passing in a tag name, wrap around an existing element that's already in the DOM by passing in a selector, or can receive an HTMLElement reference. CTag will also receive a list of children, which can be a CTag, a string, or an HTMLElement.

CTag <!-- omit in toc -->

Okay, let's look at some code:

const card = new CTag('div', [new CTag('p'), 'text', new CTag('(body)')]);
Enter fullscreen mode Exit fullscreen mode

By wrapping (body) with parenthesis we specify to CTag that we want to select that element instead of creating a new tag.

I don't like using new each time we create a new CTag, so I will also add a function to create tags, to make it cleaner:

const card = tag('div', [tag('p'), 'text', document.querySelector('body')]);
Enter fullscreen mode Exit fullscreen mode

This is cool and all, but it's a bit redundant to have to write tag and the tag name each time right? What if we create a function for each tag?
So it looks something like this:

const card = div(p("Hello world"));
Enter fullscreen mode Exit fullscreen mode

Much better, but how do we do this? It's actually not that hard, first, we just need a list with all the HTML tags. Then we can create a function for each tag from that list.

There are a couple of ways of doing this:

Create an object that will hold all the tag functions <!-- omit in toc -->

Then iterate our list and add them to the object. Like this:

export const allTags = {  };

for(const tagName of allTagNames) {
    allTags[tagName] = (...children) => tag(tagName, ...children);
}
Enter fullscreen mode Exit fullscreen mode

This is fine and could do the job. The thing is, this will generate all the functions even if the user does not want to use them all.
This will use memory that's not needed. The next option fixes this issue.

Using a Proxy <!-- omit in toc -->

Oh good old proxy, are you useful sometimes!

Okay, so we want to make it so that the tag function will only be created when we GET the tag function. Instead of being created all at once.
This can be accomplished by using a Proxy instead of a plain object. How? you ask? Quite simple, like this:

First replace the object with a proxy instance

const allTags = new Proxy({}, {});
Enter fullscreen mode Exit fullscreen mode

Now we create a custom handler

export const allTags = new Proxy({}, {
    get(target, property) {
        // Do something when someone wants to get a property
    }
});
Enter fullscreen mode Exit fullscreen mode

The get function in the handler will be called any time any property (defined or not) is being accessed.

Now, we can return a tag function each time a property is accessed:

export const allTags = new Proxy({}, {
    get(target, tagName) {
       return (...children) => tag(tagName, ...children);
    }
});
Enter fullscreen mode Exit fullscreen mode

It's now possible to call allTags.div(), allTags.p(), etc...

The only drawback is that Typescript does not know what tags are available inside allTags. This is simple to fix though. We just need to create a custom type for all tags:

type AllTags = {
  [key in ValidTagName]?: ((...children: TagChildren) => CTag);
};

export const allTags: AllTags = new Proxy({}, {
    get(target, tagName) {
       return (...children) => tag(tagName, ...children);
    }
});
Enter fullscreen mode Exit fullscreen mode

ValidTagName is a string literal type containing all the tag names.

Now typescript knows that allTags is an object containing a function for each tag.

Well, it thinks there is (ts not too smart sometimes). As I've said before the function will be created when we get the tag function.

We can now use any tag we want:

const { div, p, span } = allTags;

div(p(), span());
Enter fullscreen mode Exit fullscreen mode

Great. Now let's make the tags do something. Currently, they're pretty much fluff, empty balls of nothing.


Creating HTML elements

The first thing the tag needs is to become (or create) an HTML element. Luckily we can do that very simply by using the document.createElement() function.

This is what the CTag class looks like:

class CTag {
    ...
    element: HTMLElement;
    constructor() {
        this.element = document.createElement(tagName) as HTMLElement; // cast to HTMLElement as createElement returns a Node
    }
    ...
}
Enter fullscreen mode Exit fullscreen mode

Now we have a reference to an HTMLElement. This element can then be added to the DOM and manipulated.
We still haven't added it to the DOM though, so let's do that.

Adding element to the DOM

You might be thinking, "How do we know to which element we want to add the new tag to?".
Well, we could approach this in a couple of ways. We could just add it to the body. But what if the user wants to add it inside another element?

Following what I did with Hobo, I want to add the concept of attaching. There will be a context, this context will hold the currently attached tag.
This means that when adding a new tag if there's an attached tag, the new tag will be added as a child.

Let's visualize it:

// Creates a new CTag, but instead of creating a new tag, it will select the body from the page.
const body = tag('(body)');
attach(body);

div(); // Div is added as a child of the body
Enter fullscreen mode Exit fullscreen mode
  1. We select the body
  2. We attach to the body
  3. div is added as a child of the body NOTE: we can also manually add children by calling .add(...children) on any given CTag

Attaching is not limited to a single tag. We can attach multiple times:

const body = tag('(body)');
attach(body);
div();

const wrapper = div();
attach(wrapper);
p("I'm a child of wrapper!");
detach();

p("I'm a child on body again!!");

detach();

p(); // This tag will not be added to the dom as there are no attached tags
Enter fullscreen mode Exit fullscreen mode

Note that we can call detach, to go back to the previously attached tag!
A thing to consider though is that if you call attach with only one attached tag, it will detach and there will be no attached tag.


But how does CTag now where to be added to? This is where the context comes in.

We create an object that represents the context:

export let context: {
  attachedTag: CTag;
  attachedTagStack: CTag[];
} = {
  attachedTag: null,
  attachedTagStack: [],
};
Enter fullscreen mode Exit fullscreen mode

When calling attach() it will update this context and will set the attached tag.

Then when creating a new CTag, we can use this context to determine where it should be added.

Inside the CTag constructor, we can do:

if (context.attachedTag) {
  context.attachedTag.add(this);
}
Enter fullscreen mode Exit fullscreen mode

.add() will add this tag as a child.

!The problem: There's a little caveat with this though. What happens if we add a tag as a child?

If I've explained myself well enough you might've realized already. The child is added first to the attached tag (body), then it will be removed from the body and added as a child of the correct parent. But let's look at this in code:

attach(body);

// Div will be correctly added to the body
div(
    // This tag (p), will be first added to the body as it's the attached tag at this point.
    // Then it will be added as a child of the div.
    p(), 
);
Enter fullscreen mode Exit fullscreen mode

In the end, you will not notice, as it's done incredibly fast. But, it's doing stuff it does not need to do, and I don't like that.

So, the solution I first came up with was to allow tags to be silent, which means that if marked as silent, they will not be added to the attached tag.

To do this I thought of adding a custom method (called silent) to the tag functions. This way you can call the silent method if you don't want to attach.

This is what I mean:

div(
    p.silent(), // will not be added to the body first
);
Enter fullscreen mode Exit fullscreen mode

This has a problem in itself: More times than not, tags will be children of other tags. So most tags will be marked as silent, adding unnecessary noise. I finally opted to do it in reverse, tags that you want to attach must be marked as attachable instead. So any time you want to add a tag to the attached tag you can call the .attach() method.

div.attach(
    p(), // will be silent by default!
);
Enter fullscreen mode Exit fullscreen mode

Finally, we can add stuff to the DOM. Now let's look at how we can interact and modify it.


Interacting with the DOM

For Cardboard to be useful it must make it easier to interact with, and modify, the elements without having to call methods in the element itself.
For example, instead of doing this:

div().element.innerContent = 'Hello world!'
Enter fullscreen mode Exit fullscreen mode

It could be done like this:

div().text('Hello world!');
Enter fullscreen mode Exit fullscreen mode

The last example has a couple of benefits:

  • Simplifies the code
  • Allows us to chain calls on the tag.
div().text('Hey DEV!').addStyle('color', 'red');
Enter fullscreen mode Exit fullscreen mode

Otherwise, it would become this:

const box = div();
box.element.innerContent = 'Hey DEV!';
box.element.style.color = 'red';
Enter fullscreen mode Exit fullscreen mode

Setting styles

As shown in the previous example, it's possible to set styles of a tag. There are a couple of ways:
Add single style:

div().addStyle('color', 'red'); // <div style="color: red;"></div>
Enter fullscreen mode Exit fullscreen mode

Set multiple styles:

div().setStyle({color: 'red', fontWeight: 'bold'});
Enter fullscreen mode Exit fullscreen mode

Remove styles:

div().rmStyle('color', 'fontWeight');
Enter fullscreen mode Exit fullscreen mode

Setting classes and IDs

One key thing in HTML is that elements can have an ID and a set of classes. Used for applying CSS styles, and for getting the elements later on. So cardboard must handle this as well. For that reason, tags have a couple of methods for adding and removing classes and setting the id.

Set the id:

div().id('wrapper'); // <div id="wrapper"></div>
Enter fullscreen mode Exit fullscreen mode

Add classes:

div().addClass('wrapper', 'box'); // <div class="wrapper box"></div>
Enter fullscreen mode Exit fullscreen mode

Remove classes:

div().rmClass('wrapper', 'box');
Enter fullscreen mode Exit fullscreen mode

Has classes:

div().hasClass('wrapper', 'box');
Enter fullscreen mode Exit fullscreen mode

Adding attributes

Another key concept for HTML is that tags have attributes. In fact, both the classes and ID are attributes, but are handled independently.

Now, attributes are very important for writing HTML pages. You want a link to open a site when clicked? You need to set the href attribute. You want to load an image? You have to set the src attribute on the image tag. You get the point.

Of course, this is handled by Cardboard.

Add a single attribute:

img().addAttr('src', '<link>'); // <img src="<link>" />
Enter fullscreen mode Exit fullscreen mode

Set multiple attributes:

img().setAttr({'src': '<link>', 'alt': '<alt_text>'}); // <img src="<link>" alt="<alt_text>"/>
Enter fullscreen mode Exit fullscreen mode

Remove attributes:

div().rmAttr('src', 'alt');
Enter fullscreen mode Exit fullscreen mode

Modifying tag in a single call

I've also added a method to configure a tag in a single call. This might be useful in certain scenarios, as you can configure a tag by passing in an object:

div().config({
  id: 'wrapper',
  attr: {'src': 'alt'},
  class: ['box'],
  text: 'Hello world!',
  on: {
    click: (_, __) => {}
  }
});
Enter fullscreen mode Exit fullscreen mode

Nice, we can now modify elements as we please. Next, we need to be able to listen to interactions on the page.

Events

Every web application needs to listen to, and trigger events. This is a way of reacting to changes and interactions on the page.
Plain JavasScript allows us to do this by using element.addEventListener() on any HTMLElement, or by setting the on event element.onclick = fn;.

I want cardboard to be a little bit more ergonomic to add listeners. First I will add a generic function, similar to addEventListener called on. It's the same but with a shorter name.

Tags can now do this:

button().on('click', (self, evt) => console.log('clicked'));
Enter fullscreen mode Exit fullscreen mode

I also want to add a few shorthands for common events, like, click, keypress, change, etc...

button().click((self, evt) => console.log('clicked'));
Enter fullscreen mode Exit fullscreen mode

Does the job.

Another thing I'd like is for a tag to be able to listen to another tag's events. Why? This way we can react when something happens elsewhere from within the tag that will react instead of from the tag that raised the event.

It might be easier to show with code:

Take this scenario:

You have an input, and a button after it. You want to make it so that the button is disabled if there's no value inside the input.

This is how it should be done without listening:

const addField = button();
const inputField = input().on('input', (self, evt) => {
    if(self.value) {
        addField.enable();
    } else {
        addField.disable();
    }
});

div(inputField, addField);
Enter fullscreen mode Exit fullscreen mode

This is how it should be done with listening:

const inputField = input();
const addField = button().listen(inputField, 'input', () => {
    if(self.value) {
        addField.enable();
    } else {
        addField.disable();
    }
});

div(inputField, addField);
Enter fullscreen mode Exit fullscreen mode

There's nothing wrong per se in the first case, just a couple of inconveniences in some situations.
For example, in the listen case, we can define elements in order. In the first case, we could not.
It encapsulates logic that will affect the button within itself. If you want to know where the button gets disabled, you know where to look.


The state

This one was kinda tricky to get working, and it might not be the most efficient. But I think it will do the job for now.

Let's take a look at the state example again:

const counterState = state({ count: 0 });
p().consume(
    counterState.count, 
    (self, count) => self.text(`Clicked ${count} times`)
);
counterState.count++;
Enter fullscreen mode Exit fullscreen mode

So what's going on here to make this possible then? Well, it's more proxy magic and some basic language knowledge. First, let's look at the state itself, how exactly is it created, and how can we listen to changes in the values of the state.

Creating the state. I'm going to simplify the code to make it easier to explain, but you can see the complete code here.

This will add a Proxy over the provided object:

export function state(content: {}) {
    const _propListeners = {};
    const _stateListeners = [];

    const addListener = (prop, callback) => {
        if (!_propListeners[prop]) _propListeners[prop] = [];
        _propListeners[prop].push(callback);
    };

    const emitChange = (target, prop) => {
        // Call emitters for the target property and the complete state
    };

    const addChangedMethod = (target, prop) => {
        const value = target[prop];
        value.changed = (callback) => addListener(prop, callback);
    };

    // Make nested objects and arrays also states
    for (let prop of Object.getOwnPropertyNames(content)) {
        if (isObject(content[prop])) {
            content[prop] = state(content[prop]);
        } 
        else if (content[prop] instanceof Array) {
            content[prop] = state(content[prop]);
        }
    }

    const proxy = new Proxy(content, {
        get(target, prop) {
            return addChangedMethod(target, prop);
        },
        set(target, prop, value) {
            target[prop] = value;
            emitChange(target, prop);
            return true;
        },
        deleteProperty(target, prop) {
            delete target[prop];
            emitChange(target, prop);
            return true;
        },
    });

    proxy.changed = (callback) => {
        _stateListeners.push(callback);
    };

    return proxy;
}
Enter fullscreen mode Exit fullscreen mode

The above piece of code will allow us to call .changed(callback) on any state or property. And will also be triggered when modifying an array with methods like push and pop.
It also allows us to listen to some properties of the array, i.e. length.

Let's look at some examples <!-- omit in toc -->

  • Simple example:
const counterState = state({ count: 0 });
counterState.changed((newState) => console.log(count));
counterState.count.changed((count) => console.log(count));
counterState.count += 1;
Enter fullscreen mode Exit fullscreen mode
  • Array example:
const listState = state({ list: [] });
counterState.list.changed((list) => console.log(list));
counterState.list.length.changed((length) => console.log("List length: " + length));
counterState.list.push("Item");
Enter fullscreen mode Exit fullscreen mode
  • Object example:
const nestedState = state({ data: { name: '' } });
nestedState.data.name.changed((name) => console.log(name));
nestedState.data.name = "new name";
Enter fullscreen mode Exit fullscreen mode

The bulk of Cardboard is done. <!-- omit in toc -->

At this point, we have something that can make an interactive site. Not convinced? Me neither, so let's write a TODO list app using Cardboard and see if it's possible!

Todo List App

First let's prepare the project by setting up some styles and a font:

import styles from './style.js';
import { init, allTags, hinput, hstyle, attach, tag, state } from '../../dist/cardboard.js';
const { div, button, h3, link, p, span } = allTags;


const pageLinks = [
  'https://fonts.googleapis.com',
  'https://fonts.gstatic.com',
  'https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100;0,300;0,400;0,600;0,700;1,100;1,300&display=swap',
];
const makeLinks = () => pageLinks.map((url) => link().setAttr('href', url));

init();

// Adds base styles so that the HTML does not destroy your eyes! 
hstyle();

tag('(head)').add(...makeLinks());

// Adds custom styles
styles();
Enter fullscreen mode Exit fullscreen mode

Now we need a way of storing the TODOs. I also want it to be stored in the Local Storage. This is how it can be done:

const todoState = state({
  items: [...JSON.parse(localStorage.getItem('TODOS'))],
});

todoState.items.changed((newItems) => {
  localStorage.setItem('TODOS', JSON.stringify([...newItems]));
});
Enter fullscreen mode Exit fullscreen mode

Any time the items change (added or removed), it will update the localStorage automatically. When we load the page, any stored todos will be added to the items state.

Now for the app itself:

const todoApp = div.attach().addClass('todo-app');
attach(todoApp);

const itemInput = hinput({
  placeholder: 'Enter item content',
  submit: addItemFromInput, // When enter is pressed, add an item from the input
});

const addItemBtn = button('+')
  .addClass('btn-add')
  .disable()
  // We enable and disable the button based on the state of the input :)
  .listen(itemInput, 'input', (self, other) => (other.value ? self.enable() : self.disable()))
   // When the button is pressed, add an item from the input
  .clicked(addItemFromInput);

// Automatically update the todo count whenever a new item is added or removed
// Note that we listen to the array.length property directly :)
const todoCount = span().consume(todoState.items.length, (self, count) => self.text(` (count: ${count}) `));

h3.attach('Cardboard TODO', todoCount).addStyle({ textAlign: 'center', margin: '24px 0' });
div.attach(itemInput, addItemBtn).addClass('header');

const todoList = div
  .attach(
    // No items message will be shown if there are no items in the list, and hidden if there are
    p('There are no items')
      .addClass('list-empty')
      .consume(todoState.items.length, (t, v) => {
        t.setStyle('display', v ? 'none' : 'block');
      }),
  )
  .addClass('todo-list');
Enter fullscreen mode Exit fullscreen mode

Adding items to the list

// Adds an item to the list (to the tag, not the state)
function addItem(value: string) {
  if (value) {
    todoList.add(
      todoItem(value, {
        remove: (s, c) => {
          const index = todoState.items.indexOf(c);
          todoState.items.splice(index, 1);
        },
      }),
    );
  }
}

// Adds a new TODO from the input field
// Adds item to state 
// Clears the input
function addItemFromInput() {
  if (itemInput.value) {
    addItem(itemInput.value);
    todoState.items.push(itemInput.value);
    itemInput.clear();
  }
}
Enter fullscreen mode Exit fullscreen mode

The code for the todoItem component:

export default function todoItem(content: string, opts: { remove: (self: CTag, content: string) => void }) {
  const removeItem = (self) => {
    if (opts.remove) {
      opts.remove(self, content);
    }
    self.remove();
  };

  return div(
    content,
    button('-')
      .addClass('btn-remove')
      .clicked((self) => removeItem(self.parent)), // self.parent will be div
  ).addClass('todo-item');
}
Enter fullscreen mode Exit fullscreen mode

Populating the list. If there are items in the state initially, we want to add them to the list:

for (const item of [...todoState.items]) {
  addItem(item);
}
Enter fullscreen mode Exit fullscreen mode

The complete code for this example can be found here.

The result

Image description

Summary

So, here's the deal: we've given birth to Cardboard, a little library that's got some real-time flair an is very similar in API to Hobo! We also broke down the nitty-gritty details, from creating HTML elements with a breeze to handling events and even adding a sprinkle of state magic.

Whether you're a coding veteran or just getting started, I invite you to experiment with Cardboard and let me know if it's valuable or not. And if you're looking for a project to contribute to, I'd be more than happy to receive help!

If you've made it this far, thanks for coming along for the ride. That's a wrap for this one! Have fun coding, and remember, the web's your playground. 🚀

That's me for this one! Have a nice one :)

GitHub logo nombrekeff / cardboard-js

A very simple, yet powerful reactive framework, to create web applications. All of this with, no HTML, CSS, JSX, and no required build.

📦 Carboard.js

Tests Main Project Status

Welcome to Cardboard. An extremely light (around 18kb), performant, and very simple reactive framework. It offers almost everything you'd expect from a complete framework. Like managing state, components, logic, and the rest. But with a twist, you don't need to write any HTML, CSS, or JSX if you don't want to. See what it can do.

It's similar in philosophy to VanJS, if that rings a bell, but with many more features, and a more extensive API.

!NOTE!: Cardboard is in development, use it with caution.
You can check the v1.0.0 milestone for a view on the development state - help is much appreciated!

const Counter = () => {
  const count = state(0);
  return button()
    .text(`Clicked $count times`, { count })
    .addStyle('color', 'gray')
    .
Enter fullscreen mode Exit fullscreen mode

Top comments (1)

Collapse
 
artydev profile image
artydev

Nice :-)
Have a look to VanJS
Regards