Skip to content

artofcodelabs/simplicit

Repository files navigation

🧐 What is Simplicit?

Simplicit is a small library for structuring front-end JavaScript around controllers and components.

On the MVC side, it mirrors the “controller/action” convention you may know from frameworks like Ruby on Rails: based on <body> attributes, it finds the corresponding controller and calls its lifecycle hooks and action method.

On the component side, it provides a lightweight runtime (start() + Component) that instantiates and binds components from data-component, builds parent/child relationships, and automatically tears them down when elements are removed from the DOM.

🤝 Dependencies

Simplicit relies only on dompurify for sanitizing HTML.

📲 Installation

$ npm install --save simplicit

🎮 Usage

🖲️ Components

Simplicit ships with a small component runtime built around DOM attributes.

✅ Quick start

import { start, Component } from "simplicit";

class Hello extends Component {
  static name = "hello";

  connect() {
    const { input, button, output } = this.refs();
    this.on(button, "click", () => {
      output.textContent = `Hello ${input.value}!`;
    });
  }
}

document.addEventListener("DOMContentLoaded", () => {
  start({ root: document, components: [Hello] });
});
<div data-component="hello">
  <input data-ref="input" type="text" />
  <button data-ref="button">Greet</button>
  <span data-ref="output"></span>
</div>

DOM conventions

  • data-component="<name>": marks an element as a component root.
    • <name> must match the component class’ static name.
    • <script> tags are never treated as components, even if they have data-component.
  • data-component-id="<id>": set automatically on every element with data-component (each component instance).
    • Also available as instance.componentId.
  • data-ref="<key>": marks ref elements inside a component (see ref() / refs()).

start({ root, components })

start() scans root (defaults to document.body) for elements with data-component, creates and binds component instances for them, and keeps them in sync with DOM changes (new elements get initialized, removed ones get disconnected).

  • Validation
    • Throws if there are no data-component elements within root.
    • Throws if the DOM contains data-component="X" but you didn’t pass a matching class in components.
    • Throws if any provided component class does not define a writable static name.
  • Lifecycle
    • When an instance is created, if it has connect(), it is called after the instance is bound to its root DOM element (available as this.element).
    • When a component element is removed from the DOM, its instance.disconnect() is called automatically.

Return value

start() returns an object:

  • roots: array of root component instances (components whose parent is null) discovered at startup.
  • addComponents(newComponents): registers additional component classes later.
    • Validates the DOM again.
    • Scans the existing DOM for elements with data-component matching the newly added classes and initializes those that weren’t initialized yet.
    • Returns the newly created instances (or null if nothing was added).

Base class: Component

Simplicit exports a Component base class you can extend.

Core properties

  • element: the root DOM element of the component (data-component="...").
  • node: internal node graph { name, element, parent, children, siblings }.
  • componentId: string id mirrored to data-component-id.
  • parent: parent component instance (or null for root components).

Relationships

All relationship helpers filter by component name(s):

  • children(nameOrNames): direct children component instances (DOM order).
  • siblings(nameOrNames): sibling component instances.
  • ancestor(name): nearest matching ancestor component instance (or null).
  • descendants(name): all matching descendants (flat array).

Refs

Refs are scoped to the component’s root element.

  • ref(name): returns null, a single element, or an array of elements (when multiple match).
  • refs(): returns an object mapping each data-ref key to Element | Element[]. Only elements inside the component that have data-ref are included.

Cleanup & lifecycle utilities

disconnect() runs cleanup callbacks once and detaches the instance from its parent/child links.

You can register cleanup manually or use helpers that auto-register cleanup:

  • registerCleanup(fn)
  • on(target, type, listener, options) (auto-removes the listener on disconnect)
  • timeout(fn, delay) (auto-clears on disconnect)
  • interval(fn, delay) (auto-clears on disconnect)

Server-driven templates via <script type="application/json">

If a component class defines static template(data), Simplicit can render HTML from JSON embedded in the page.

import { start, Component } from "simplicit";

class Slide extends Component {
  static name = "slide";
  static template = ({ text }) => `<div data-component="slide">${text}</div>`;
}

start({ root: document, components: [Slide] });
<div id="slideshow"></div>

<script
  type="application/json"
  data-component="slide"
  data-target="slideshow"
  data-position="beforeend"
>
  [{"text":"A"},{"text":"B"}]
</script>

Notes:

  • The JSON payload must be an array; each item is passed to ComponentClass.template(item).
  • The rendered HTML is sanitized with dompurify before being inserted.
  • data-target must match an existing element id, otherwise an error is thrown.
  • Insertion uses targetEl.insertAdjacentHTML(position, html) where position comes from data-position (default: beforeend). Valid values: beforebegin, afterbegin, beforeend, afterend.
  • Inserted component elements are then auto-initialized like any other DOM addition.

🕹️ Controllers

Simplicit must have access to all controllers you want to run. In practice, you build a Controllers object and pass it to init().

Example:

// js/index.js (entry point)

import { init } from 'simplicit';

import Admin from "./controllers/Admin.js"; // namespace controller
import User from "./controllers/User.js";   // namespace controller

import Articles from "./controllers/admin/Articles.js";
import Comments from "./controllers/admin/Comments.js";

Object.assign(Admin, {
  Articles,
  Comments
});

const Controllers = {
  Admin,
  User
};

document.addEventListener("DOMContentLoaded", function() {
  init(Controllers);
});

💀 Anatomy of the controller

Example controller:

// js/controllers/admin/Articles.js

import { helpers } from "simplicit";

import Index from "views/admin/articles/Index.js";
import Show from "views/admin/articles/Show.js";

class Articles {
  // Simplicit supports both static and instance actions
  static index() {
    Index.render();
  }

  show() {
    Show.render({ id: helpers.params.id });
  }
}

export default Articles;

Minimal view example (one possible approach):

// views/admin/articles/Show.js

export default {
  render: ({ id }) => {
    const el = document.getElementById("app");
    el.textContent = `Article ${id}`;
    // If you need data loading, you can fetch here and update the DOM after.
  },
};

👷🏻‍♂️ How does it work?

On DOMContentLoaded, Simplicit reads these <body> attributes:

  • data-namespace (optional): a namespace path like Main or Main/Panel
  • data-controller: controller name (e.g. Pages)
  • data-action: action name (e.g. index)
<body data-namespace="Main/Panel" data-controller="Pages" data-action="index">
</body>

Then it resolves the matching controller(s), runs lifecycle hooks, and calls the action.

Resolution rules (simplified):

  • If data-namespace resolves (e.g. Main/PanelControllers.Main.Panel), Simplicit initializes the namespace controller and resolves the page controller under it (e.g. Controllers.Main.Panel.Pages).
  • Otherwise it skips the namespace controller and falls back to Controllers.Pages.

Call order (per controller):

  • If a method exists as static or instance, Simplicit will call it.
  • On navigation/re-init, previously active controllers receive deinitialize() (if present).
namespaceController = new Controllers.Main.Panel;
Controllers.Main.Panel.initialize();               // if exists
namespaceController.initialize();                  // if exists

controller = new Controllers.Main.Panel.Pages;
Controllers.Main.Panel.Pages.initialize();         // if exists
controller.initialize();                           // if exists
Controllers.Main.Panel.Pages.index();              // if exists
controller.index();                                // if exists

You don’t need controllers for every page; if a controller/method is missing, Simplicit skips it.

The init function returns { namespaceController, controller, action }.

Ruby on Rails: generating <body> data attributes

If you want Rails to generate the controller metadata for Simplicit automatically, you can derive it from controller_path, controller_name, and action_name.

This version supports nested namespaces like Main/Panel (any depth):

# app/helpers/application_helper.rb

module ApplicationHelper
  def simplicit_body_attrs(default_namespace: nil)
    namespace = controller_path
      .split("/")
      .then { |parts| parts[0...-1] } # everything except the controller name
      .map(&:camelize)
      .join("/")

    # If you want a default namespace (e.g. "Main") for non-namespaced controllers:
    namespace = default_namespace if namespace.blank? && default_namespace

    {
      data: {
        namespace: namespace.presence,           # -> data-namespace="Main/Panel"
        controller: controller_name.camelize,    # -> data-controller="Articles"
        action: action_name,                     # -> data-action="index"
      }.compact,
    }
  end
end
<%= content_tag :body, simplicit_body_attrs(default_namespace: "Main") do %>
  <%= yield %>
<% end %>

🛠 Helpers

Simplicit exports helpers object that has the following properties:

  • params (getter) - facilitates fetching params from the URL

👩🏽‍🔬 Tests

npx playwright install

npm run test

npx playwright test --headed e2e/slideshow.spec.js

📜 License

Simplicit is released under the MIT License.

👨‍🏭 Author

Zbigniew Humeniuk from Art of Code

About

A truly modest yet powerful JavaScript framework

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors