uuuf is a simple, js only, behavior component framework.
It's goal is to ease development of frontend behavior for projects where other more powerful frameworks (es: React) can't be used for whatever reason.
uuuf is not going to replace other frameworks, and how you handle templates, styles or state management is entirely up to you.
Given this html (doesn't matter how it's generated as long as is available when uuuf loads):
<div data-js-component="Counter">
<button type="button" data-dec-btn="10">dec by 10</button>
<button type="button" data-dec-btn="1">dec by 1</button>
<span class="amount" data-amount></span>
<button type="button" data-inc-btn="1">inc by 1</button>
<button type="button" data-inc-btn="10">inc by 10</button>
<div>
<button type="button" data-set-btn="0">reset</button>
<button type="button" data-set-btn="100">set to 100</button>
</div>
</div>
This is the component that handles the counter logic (component/Counter.js
):
import { Component, $$ } from 'uuuf'
class Counter extends Component {
get CSS() {
return {
tooMuch: 'amount--toomuch'
}
}
get DOM() {
return {
amount: '[data-amount]',
ctas: {
inc: [$$`[data-inc-btn]`, {
click: this.handleIncrement
}],
dec: [$$`[data-dec-btn`, {
click: this.handleDecrement
}],
set: [$$`[data-set-btn]`, {
click: this.handleSet
}]
}
}
}
async ready() {
this.bind();
this.amount = 0;
}
handleIncrement(evt) {
const btn = evt.currentTarget
const amount = Number(btn.dataset.incBtn)
this.amount += amount;
this.updateAmount();
}
handleDecrement(evt) {
const btn = evt.currentTarget
const amount = Number(btn.dataset.decBtn)
this.amount -= amount;
this.updateAmount();
}
handleSet(evt) {
const btn = evt.currentTarget
const amount = Number(btn.dataset.setBtn)
this.amount = amount;
this.updateAmount();
}
updateAmount() {
// print value
this.dom.amount.innerHTML = this.amount;
// apply css class if amount is > 100, remove if <= 100
this.css.tooMuch(this.dom.amount, this.amount > 100);
// disable inc buttons if amount is > 100, enable if <= 100
this.dom.ctas.inc.forEach(e => e.disabled = this.amount > 100);
}
}
uuuf is very flexible about how you load components into the application. This is the reason you have to do come configuration prior to using it.
Given the following project structure:
myproject/
├── node_modules/
│ ├── ...
│ └── uuuf/
├── components/
│ └── HelloWorld.js
├── loadComponents.js
├── index.js
└── index.html
This is how you initialize uuuf into your application:
loadComponents.js
:
import { makeLoadComponents } from 'uuuf';
async function importComponent(componentName) {
// this is an example with webpack dynamic imports
return import(`@/components/${componentName}`).then(mod => mod.default);
}
export default makeLoadComponents(importComponent);
components/HelloWorls.js
:
import { Component } from 'uuuf'
class HelloWorld extends Component {
async ready() {
console.log('hello, world!')
}
}
index.js
:
import loadComponents from './loadComponents';
loadComponents(document.body);
uuuf was designed with a few principles that might diverge from typical vanilla js development that are worth keeping in mind:
- Automatic method binding
Methods declared in a component are automatically bound to the component instance. This removes the need to keep track of the execution context and allows methods to be called worry-free from anywhere you need.
- Declarative syntax for DOM access
You might have noticed the lack of querySelector/querySelectorAll
methods in the previous examples. This is because you specify what you want and with which name in the DOM
getter. Obviously uuuf uses querySelector
and querySelectorAll
behind de curtains, but that is entirely abstracted away.
You can and should still use them from time to time, especially for niche situations. But most of the time, you're fine with DOM
getter.
- Component responsibility
Each component should handle itself and is responsible for the DOM portion it is attached to. This means that in a parent-child relationship between component, the parent component is responsible for child components loading. Each component instance is by default attached to the DOM element it controls, which ease access from external code and other components.
Loose coupling is fine, and tipically comunication is done in two fashions:
- top-down: use method calls on receivng component
- bottom-up: emit events from calling component
- Query strategy
In order to ease component responsibility, querySelector
and querySelectorAll
aren't really a good fit for the purpouse. Imagine this scenario:
<ul data-js-component="List">
<li class="level-1" data-js-component="ListItem">
<ul data-js-component="List">
<li class="level-2" data-js-component="ListItem"></li>
</ul>
</li>
<li class="level-1" data-js-component="ListItem"></li>
</ul>
If List
component had access only to querySelector
and querySelectorAll
,
and wanted to get a reference to it's .level-1
ListItem
children, we have some problems:
querySelector('[data-js-component="ListItem"]')
only matches the first onequerySelectorAll('[data-js-component="ListItem"]')
would match also.level-2
items, breaking the component responsibility principle.- use
querySelectorAll('.level-' + level)
, which complicates desing of the component (and is not encouraged, since css class should only handle styles).
This is why uuuf implements a custom query stategy, called uuuf.query
, which goes as follow:
- starts from a given root, which can be a either a list of elements or a single one (it will be normalized to a list of elements)
- for each element in the given root, check if the element matches the given css selector
- if an element matches, collect the element and stop
- otherwise, descend into its children and use them as the new root for the next recursion step
A wise man once said a picture is worth a thousand words, so let's double that:
uuuf.makeLoadComponents( importComponent: async (componentName: string) => Promise<ComponentClass>, { componentSelector?: string, getComponentName?: (elem: HTMLElement) => string, } ) => LoadComponentsFn
This function creates a loadComponents
function used to import components at runtime.
importComponent
is the only mandatory parameter, which handles how a component is imported given it's name.
componentSelector
defaults to [data-js-component]
and must be a valid css selector.
getComponentName
is tasked with the extraction of the component name from the element. Defaults to elem => elem.dataset.jsComponent
async loadComponents( root: HTMLElement | HTMLElement[] | HTMLCollection, extraPredicate?: (elem: HTMLElement) => boolean ) => Promise<void>
This function searches components in the dom starting from root
.
extraPredicate
can be a optional predicate function to further exclude matches.
uuuf.query( root: HTMLElement | HTMLElement[], predicate: string | ((e: HTMLElement) => boolean) ): HTMLElement[]
The function implementing the query strategy explained in the 4th design principle.
If predicate
is a string, it must be a valid css selector.
uuuf.$$
String template tag used in this.DOM
to express usage of uuuf.query
as strategy.
uuuf.$ALL
String template tag used in this.DOM
to express usage of elem.querySelectorAll
as strategy.
uuuf.$DOC
String template tag used in this.DOM
to express usage of document.querySelector
as strategy.
uuuf.$$DOC
String template tag used in this.DOM
to express usage of document.querySelectorAll
as strategy.
uuuf.$UP
String template tag used in this.DOM
to express usage of elem.closest
as strategy.
uuuf.emit( elem: HTMLElement, name: string, detail: any, bubbles = true )
Helper function to emit custom events on DOM elements.
class Component { // Getters get CSS(): ObjectTree<string> { return { /* myClass: 'my-class', */ }; } get DOM(): ObjectTree<DOMDefinition> { return { /* myElement: '[data-my-element]', myElement: ['[data-my-element]', { click: () => console.log("hello, world!"), }], myGroup: { mySubGroup: { elem1: '[data-elem-1]', elem2: ['[data-elem-2]', { click: () => console.log("hello, world!"), }] } }, */ }; } // Component lifecycle constructor(elem: HTMLElement) async ready() // Methods select() bind() unbind() emit(name: string, detail: any, bubbles = true) is(e: any): boolean async mix(component: string | Component, elem = this.elem): Promise<Component> // Public fields elem: HTMLElementComponent; args: { [key: string]: any }; css: ObjectTree<CSSClass>; dom: ObjectTree<QueryResult>; }
Dat chunky boi is the base class to extend into your components.
get CSS(): ObjectTree<string>
Should return an object that mimics a tree, whose keys are user defined names and values can be either other object-trees or strings that will be transformed into css class helpers.
The result with the css class helpers is exposed as this.css
get DOM(): ObjectTree<DOMDefinition>
Similar to get CSS
, but leaves can either be:
- a string representing a valid css selector
- a
$$
or$ALL
tagged string representing a valid css selector - a tuple which first element is either a simple or
$$
/$ALL
tagged string, and second element is a object whose keys are event names and values are corresponding event handlers.
When this.select()
is invoked, it reads this.DOM
to get the list of dom nodes to retrieve. Depenging on which kind of string is supplied as value or as first element of the tuple, different strategies are used:
- simple string =>
this.elem.querySelector(selector)
$ALL
string =>this.elem.querySelectorAll(selector)
$$
string =>uuuf.query(this.elem, selector)
For multiple matches, $$
is preferred over $ALL
, as it helps ensuring component responsibility over the DOM. All strategies use the element on which the component is attached as the root of the search.
Results of the queries are exposed as this.dom
, with the tree structure preserved.
When this.bind
is invoked, first it automatically invokes this.select
. Then, it reads only tuple leaves, and uses the second field to bind event handlers to the specified event on the matched element.
constructor(elem: HTMLElement) => Component
The component constructor.
elem
is the DOM element matched by loadComponents
during initialization.
Initialization code should usually go into async ready()
, since the order of component creation is not guaranteed, this.dom
/this.css
aren't initialized yet and async code in the constructor is uncomfortable to handle.
async ready(): Promise<void>
ready
is the main lifecycle method of a component, is called by loadComponents
and serves three purposes:
- run code after the component instance is created and attached to the dom
- do component initialization duties, including loading other components if needed
- signal to
loadComponents
that this component is ready and continue with initialization of the next component
By default, the implementation of this method is simply
async ready() {
this.bind();
}
This method is meant to be overriden rather than extended, in order to give fine-grained control over what-happens-when (es: you might want to manipulate the dom before loading other components)
loadComponents
ensures ordering upon ready
calls, accordingly to uuuf.query
result, which follows the DOM top to bottom, outer to inner (left to right).
select()
This method reads this.DOM
tree and queries the dom accordingly, recreating the tree structure with the results in this.dom
property.
bind()
This method reads this.DOM
tree looking for event specifications, detaching registered events and attaching specified events.
Implicitly calls this.select()
and this.unbind()
.
Registered events are stored in this._handler
(not meant for direct manipulation)
unbind()
This method reads this._handler
and removes the corresponding events.
emit(name: string, detail: any, bubbles = true)
Helper method to emit custom events on the component's element.
Uses uuuf.emit()
.
The event is emitted on this.elem
.
is(e: any): boolean
Utility method that checks if the given argument is the DOM element controlled by the component.
async mix(component: typeof Component, elem = this.elem): Promise<Component>
Utility method that allows creation and initialization of other components inside a component, tipically done in ready()
.
component
is the class of the component to instantiate.
elem
is the elem
passed to component
constructor. Defaults to this.elem
Returns a reference to the mixed-in component instance.
The mixed-in component is not attached to the DOM.
It's responbility of the mixin component to store the returned reference and to eventually re-expose mixed-in component's method.
elem: HTMLElementComponent;
The element controlled by the component, to which is attached.
When loadComponents
creates components, it stores a reference to the component instance on the dom node (.component
, hidden). This mechanism allows comunication between components (es: methods calls) or from external code.
args: { [key: string]: any };
An object containing initial data to use for custom component initialization. It's a JSON.parse
of the attribute data-args
on the component's element.
css: ObjectTree<CSSClass>;
The object-tree storing CSSClass
es
dom: ObjectTree<QueryResuly>;
The object-tree storing DOM references
uuuf exports internal utilities for advanced uses
This module contains functions to manipulate object-trees
uuuf.tree.get<A>( tree: ObjectTree<A>, path: string | string[], ): ObjectTree<A> | A | undefined
Returns the value of tree
at a given path
uuuf.tree.map<A, B>( tree: ObjectTree<A>, f: ObjtreeMapFn<A, B> ): ObjectTree<B>
Maps f
to leaves of tree
. Returns a new object-tree;
This module contains function for creating CSSClass
objects
uuuf.css.cssClass(className: string): CSSClass
Returns a function with signature
cssClass(elem: HTMLElement, toggle?: boolean): boolean`
that applies className
on a given elem
.
toggle
defaults to true
. if false
, it removes className
from elem
.
This function has also a couple of useful methods:
cssClass.match(elem: HTMLElement) => boolean
Returns true if given elem
has className
applied
cssClass.toString() => string
Returns className
This modules containes functions to query the DOM
uuuf.dom.query( root: HTMLElement | HTMLElement[], predicate: string | ((e: HTMLElement) => boolean) ): HTMLElement[]
Same as uuuf.query
uuuf.dom.$$
Same as uuuf.$$
uuuf.dom.$ALL
Same as uuuf.$ALL
uuuf.dom.querySelect( elem: HTMLElement, selectorMap: ObjectTree<QuerySelector> ): ObjectTree<QueryResult>
This function is how this.dom
is built.
elem
is the root for querySelector
, querySelectorAll
and uuuf.query
search.
selectorMap
is an object-tree of DOMDefinition
s. See this.DOM
.
This module contains function for event manipulation
uuuf.events.emit( elem: HTMLElement, name: string, detail: any, bubbles = true )
Same as uuuf.emit
uuuf.events.bind( elemTree: ObjectTree<QueryResult>, handlerTree: ObjectTree<HandlerMap> ): ObjectTree<RemovableHandlerMap>
This is how this.bind()
bind events on the dom.
elemTree
is an object-tree of dom elements. Tipically uuuf.dom.querySelect
output.
handlerTree
is an object-tree of event definitons.
elemTree
and handlerTree
should have the same structure. Paths found in one tree but not in the other are ignored.
Returns an object-tree with the same structure as handlerTree
, but event handlers are replaced with functions that remove the associated handler.
uuuf.events.unbind(handlerMap: ObjectTree<RemovableHandlerMap>)
This function does the conceptual opposite of uuuf.events.bind()
. It walks an object-tree of event handler remover and calls them.
handlerMap
is tipically the output of uuuf.events.bind()
Contributions are welcome, but subjected to owner judgement.
Opening issues to discuss feature requests and bugfixes is the preferred way.
To build the library in production mode run
npm run build
To build the library in development mode run
npm run dev
or
npm run watch
To run examples, run
npm run examples
and then navigate to the url provided in the console.
Some examles require to be built, refer to their respective pakcage.json
or README
.