@schematize/ui.js
v0.3.6
Published
ui library
Downloads
314
Readme
@schematize/ui.js
Schematize UI is a collection of functions for creating UI. (I hesitate to even call it a library...)
It is:
- reactive
- small
- fast
- easy-to-use
- intuitive
- cool
- ...insert any other adjective describe today's UI libraries... ;)
But WHY? Because:
- I generally dislike JSX
<and>everywhere...but wait, it's not actually html or compatible with html- oh, and it's not syntactically js compatible either...need another interpreter/parser/compiler to convert to js
- oh, and it's also also not sytactically css compatible
- I like my SPAs to feel like an application...
- just work with code, not combination of css, html, and js in one file...keep them separate, etc
- Debugging has always been near impossible in other frameworks to me...debugging a black box feels hard/frustrating/unnecessary.
- debugging plain js is so much easier...breakpoints actually work, etc.
- I'm frustrated and confused at all of the new terminology invented by other frameworks
- controlled/uncontrolled components?
- hooks?
- useEffect?
- to be fair...names I use in this library are probably confusing too... 🙄
- I was inspired by a svelte presentation about reactivity multiple years ago
- Virtual DOM felt weird and unneccessary to me...
- why not just update what needs to be updated?
- do you really need a copy of a dom that you compare against constantly to know if something needs to change?
- There are already established patterns for event dispatching and listening...EventTarget...Can't we make use of those patterns for communicating changes or updates for reactivity?
- I wanted a faster framework...
- Which this certainly is...potentally...somewhat...faster...💨
- Plain JS almost always is faster and easier to maintain or upgrade
- When was the last time a library version was deprecated? Okay, now think about when the last time was that a JS or DOM feature was deprecated?
- TypeScript is okay, but I prefer just plain JavaScript whenever possible.
- All of the compiling/transpiling from one form to another hides what is going on underneath and that's frustrating to me
- I don't want to have to compile it for it to work...can't I just copy and paste it into a browser and have it work?
- I like having control and visibility into how a framework works
- Browser APIs are mostly enough...why are we wrapping browser APIs into frameworks to hide the browser APIs?
- Other frameworks feel mostly like hype
- And this one isn't? 😜
- I really like the angularJS version 1 way of dealing with "scopes" and inheritance of scopes
- Object prototype inheritance
- state accessible easily everywhere through property access
- I like reusibilty and components...keep that same concept...but make them easier to understand in terms of parameters, "props", flexibility, etc.
- In React or Web Components passing props as strings or primitives is not enough...I want to pass down or up any type or even references.
- I want my state to be referenced, not copied. When I make a change to an object, I don't want to have to also copy that object to all of my other states
- state management frameworks shouldn't be a thing...firing an event to update your central state across your app requires a ton of coordination...
- It was fun to re-build my own library and re-think how I might do it starting from scratch if it were my choice
Install
npm install @schematize/ui.jsimport {
// ...functions
} from '@schematize/ui.js';Hello World
Let's get started with a quick example.
Your index.html file:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Schematize UI Test</title>
</head>
<body>
<script type="importmap">
{
"imports": {
"@schematize/ui.js": "../../src/index.mjs",
"@schematize/ui.js/": "../../src/",
"@schematize/refs": "../../node_modules/@schematize/refs/main.mjs",
"@schematize/refs/": "../../node_modules/@schematize/refs/",
"@schematize/instance.js": "../../node_modules/@schematize/instance.js/src/Instance.mjs",
"@schematize/instance.js/": "../../node_modules/@schematize/instance.js/"
}
}
</script>
<script src="./app.mjs" type="module" ></script>
</body>
</html>Your app.mjs file:
import {
// less typing...
create as c,
} from '@schematize/ui.js';
// create a section element
const app = c(`section`, null, {}, (el) => [
// create an h1 element as a child of the section element
// - whose parent is the section we just created (indicated by the second parameter)
c(`h1`, el, {}, () => [
// create a text node as a child of the h1
// - with a data value of 'Hello World!'
'Hello World!'
])
]);
// append the element to the body
document.body.append(app);As you can see, all this does is create a section that has a h1 displaying 'Hello World!'.
(See ./examples/hello-world)
Core Concepts
Schematize UI.js is built around several key concepts:
1. Reactive Data Scopes
Every element has a data scope (element._) that can contain reactive data. Scopes inherit from their parent elements, creating a hierarchical data structure.
2. Proxy-based or Object-based Reactivity
Reactivity can be achieved either by using proxies or by using explicit set/deleteProperty/etc.
Data objects are wrapped in proxies that automatically trigger updates when properties change, enabling reactive UI updates.
3. Functional Element Creation
Elements are created using functions that return arrays of child elements, making the code declarative and easy to reason about.
4. Hierarchical Data Scopes
Every element has a data scope (element._) that can contain reactive data. Scopes inherit from their parent elements, creating a hierarchical data structure with prototypal inheritance.
API Reference
See API examples in action
We've provided a way to see the examples in this API reference in action. To follow the examples along:
- Start a local server from the root directory (same level as this README.md file)
- You can run
npm serveor - Run
npx http-server -p 3000
- You can run
- Run the example referenced html file in each of the sections below in a modern browser that supports ES modules.
Element Creation and DOM Manipulation
create(tagName, parent, attributes, children, scope)
Creates a new DOM element with attributes, children, and a data scope.
Parameters:
tagName(string): The HTML tag name (e.g., 'div', 'span', 'button')parent(Element|null): The parent element (null for root elements)attributes(Object): HTML attributes and propertieschildren(Function|Array): Child elements (function receives the created element)scope(Object): Data scope for the element
Example:
const button = create('button', null, {
type: 'button',
className: 'btn btn-primary',
onclick: () => console.log('clicked!')
}, (el) => [
'Click me!'
]);createNS(namespace, tagName, parent, attributes, children, scope)
Creates an element with a specific namespace (useful for SVG, MathML, etc.).
createSVG(tagName, parent, attributes, children, scope)
Convenience function for creating SVG elements (uses SVG namespace).
creator(tagName)
Creates a reusable element creator function for a specific tag name. This is useful for creating cleaner, more readable code when building complex UIs.
Parameters:
tagName(string): The HTML tag name (e.g., 'div', 'span', 'button')
Returns: A function that creates elements of the specified tag type
Example:
// Create element creators
const div = creator('div');
const button = creator('button');
const input = creator('input');
// Use creators to build UI
const myDiv =
div(null, { className: 'my-class' }, (el) => [
button(el, {
style: `
background-color: red;
color: white;
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s;
`,
onclick: () => console.log('clicked'),
}, () => ['Click me']),
input(el, {
type: 'text',
placeholder: 'Enter your name',
}, () => ['Enter your name'])
]);Try it live: http://localhost:3000/examples/creator.html
creatorNS(namespace, tagName)
Creates a reusable element creator function for a specific namespace and tag name.
Parameters:
namespace(string): The XML namespace (e.g., 'http://www.w3.org/2000/svg')tagName(string): The tag name within the namespace
Returns: A function that creates namespaced elements
creatorSVG(tagName)
Convenience function for creating SVG element creators (uses SVG namespace).
Parameters:
tagName(string): The SVG tag name (e.g., 'circle', 'rect', 'path')
Returns: A function that creates SVG elements
Example:
// Create SVG element creators
const svg = creatorSVG('svg');
const circle = creatorSVG('circle');
const rect = creatorSVG('rect');
const line = creatorSVG('line');
const text = creatorSVG('text');
// Build SVG with creators
const mySvg =
svg(null, { width: 200, height: 200, style: 'border: 1px solid #ddd;' }, (svgEl) => [
circle(svgEl, { cx: 100, cy: 100, r: 80, fill: '#007bff', opacity: 0.7 }),
rect(svgEl, { x: 50, y: 50, width: 100, height: 100, fill: '#28a745', opacity: 0.7 }),
line(svgEl, { x1: 0, y1: 0, x2: 200, y2: 200, stroke: '#dc3545', strokeWidth: 3 }),
text(svgEl, { x: 100, y: 120, textAnchor: 'middle', fill: 'white', fontSize: 16 }, () => ['SVG'])
]);createText(text, parent, fn, scope)
Creates a text node with scope assignment and optional callback function. This is useful for creating reactive text content that can be updated when data changes.
Parameters:
text(string): The initial text contentparent(Element): The parent element (for scope inheritance)fn(Function): Optional callback function that receives the created text nodescope(Object): Optional data scope for the text node
Example:
// Basic usage - create text as a child of an element
const paragraph = create('p', null, {}, (el) => [
createText('Hello, ', el), // Static text
createText('World!', el) // More static text
]);
// Reactive text that updates when data changes
const data = createProxyObject({ name: 'John', count: 0 });
const reactiveParagraph = create('p', null, {}, (el) => [
createText('Hello, ', el),
createText(data.name, el, (textNode) => {
// Listen for name changes and update the text content
listen(data, ['name'], (newName) => {
textNode.data = newName;
});
}),
createText('! You have ', el),
createText(data.count.toString(), el, (textNode) => {
// Listen for count changes and update the text content
listen(data, ['count'], (newCount) => {
textNode.data = newCount.toString();
});
}),
createText(' items.', el)
]);
// Update data to see reactive text changes
data.name = 'Jane'; // Text updates to "Hello, Jane!"
data.count = 5; // Text updates to "You have 5 items"Advanced Example with Dynamic Content:
// Create a status message that updates based on multiple data properties
const statusData = createProxyObject({
isOnline: true,
lastSeen: new Date(),
messageCount: 3
});
const statusMessage = create('div', null, { className: 'status' }, (el) => [
createText('', el, (textNode) => {
// Function to update the status text
const updateStatus = () => {
if (statusData.isOnline) {
textNode.data = `🟢 Online - ${statusData.messageCount} new messages`;
el.className = 'status online';
} else {
const timeAgo = Math.floor((Date.now() - statusData.lastSeen.getTime()) / 1000);
textNode.data = `🔴 Last seen ${timeAgo} seconds ago`;
el.className = 'status offline';
}
};
// Listen for changes to any status property
listen(statusData, ['isOnline'], updateStatus);
listen(statusData, ['messageCount'], updateStatus);
listen(statusData, ['lastSeen'], updateStatus);
// Initial update
updateStatus();
})
]);
// Update status data
statusData.isOnline = false; // Updates to "Last seen X seconds ago"
statusData.messageCount = 7; // Updates to "Online - 7 new messages" (if online)
statusData.lastSeen = new Date(); // Updates timestamp
setInterval(() => {
statusData.lastSeen = new Date();
}, 1000);Try it live: http://localhost:3000/examples/createText.html
style(styles)
Converts an object of CSS properties into a CSS string that can be used as the value for the style attribute in the create function's attributes parameter.
Parameters:
styles(Object): Object containing CSS property-value pairs
Returns:
A CSS string that can be used as the style attribute value
Example:
// Basic usage - convert object to CSS string
const buttonStyles = style({
'background-color': 'black',
color: 'white',
padding: '10px 20px',
border: 'none',
'border-radius': '8px',
cursor: 'pointer',
'font-size': '16px',
'font-weight': 'bold'
});
console.log(buttonStyles);
// Result: "background-color:blue;color:white;padding:10px 20px;border:none;border-radius:8px;cursor:pointer;font-size:16px;font-weight:bold;"
// Use with create function
const basicButton = create('button', null, {
style: buttonStyles
}, () => ['Click me']);
// Reusable style configuration
const baseStyle = {
padding: '12px 24px',
'border-radius': '6px',
border: 'none',
cursor: 'pointer',
'font-size': '16px',
'font-weight': '500'
};
const styles = {
primary: style({
...baseStyle,
'background-color': '#007bff',
color: 'white',
}),
secondary: style({
...baseStyle,
'background-color': '#6c757d',
color: 'white',
}),
success: style({
...baseStyle,
'background-color': '#28a745',
color: 'white',
}),
warning: style({
...baseStyle,
'background-color': '#ffc107',
color: '#212529',
}),
danger: style({
...baseStyle,
'background-color': '#dc3545',
color: 'white',
})
};
const styleDemo = create('div', null, { className: 'style-demo' }, (el) => [
create('button', el, { style: styles.primary }, () => ['Primary']),
create('button', el, { style: styles.secondary }, () => ['Secondary']),
create('button', el, { style: styles.success }, () => ['Success']),
create('button', el, { style: styles.warning }, () => ['Warning']),
create('button', el, { style: styles.danger }, () => ['Danger'])
]);Advanced Example with Dynamic Styling:
// Dynamic styling with reactive data
const data = createProxyObject({
theme: 'light',
size: 'medium',
isActive: false
});
const dynamicButton = create('button', null, {}, ['Dynamic Button']);
const updateStyle = () => {
dynamicButton.setAttribute('style', style({
transition: 'all 0.3s ease',
'background-color': data.theme === 'light' ? '#ffffff' : '#333333',
color: data.theme === 'light' ? '#000000' : '#ffffff',
padding: data.size === 'small' ? '5px 10px' :
data.size === 'large' ? '15px 30px' : '10px 20px',
opacity: data.isActive ? '1' : '0.7',
transform: data.isActive ? 'scale(1.05)' : 'scale(1)',
'border-radius': data.size === 'small' ? '6px' : '12px'
}));
};
listen(data, ['theme'], updateStyle);
listen(data, ['size'], updateStyle);
listen(data, ['isActive'], updateStyle);
updateStyle();Try it live: http://localhost:3000/examples/style.html
conditional(element, inits)
Creates a conditional element that can be shown or hidden based on a condition.
Parameters:
element(Element): The element to conditionally show/hideinits(Array): Array of initialization functions that set up listeners
Returns: The element with conditional behavior
toggle(element1, element2, condition, inits)
Toggles between two elements based on a condition.
Parameters:
element1(Element): First element to show when condition is trueelement2(Element): Second element to show when condition is falsecondition(Function|boolean): Condition function or boolean valueinits(Array): Array of initialization functions
onBecameVisible(element, callback)
Uses the Intersection Observer API to call a callback when an element becomes visible in the viewport.
Parameters:
element: The DOM element to observecallback(Function): Function to call when the element becomes visible (receives the intersection entry)
Example:
// Lazy load an image when it becomes visible
const image = create('img', null, { src: 'placeholder.jpg' }, () => []);
onBecameVisible(image, (entry) => {
image.src = 'actual-image.jpg';
});
// Animate elements when they come into view
const animatedDiv = create('div', null, { className: 'fade-in' }, () => ['Animated content']);
onBecameVisible(animatedDiv, (entry) => {
animatedDiv.style.opacity = '1';
animatedDiv.style.transform = 'translateY(0)';
});Data Binding and Reactivity
listen(object, properties, callback, fireImmediately)
Listens for changes to object properties and calls a callback when they change.
Parameters:
object: The object to watchproperties(Array): Array of property names to watch. Please note this is the "path" to listen to. For example, if you want to listen for the "article.author.name" (the article's author's name) you would specify ['article', 'author', 'name'].callback(Function): Function to call when properties changefireImmediately(boolean): Whether to call callback immediately
Example:
// Create user data
const userData = createProxyObject({
firstName: 'Jane',
lastName: 'Doe',
fullName: 'Jane Doe',
email: '[email protected]',
age: 25,
isActive: true,
lastLogin: new Date()
});
// Listen for changes to multiple properties
const updateFullName = () => {
userData.fullName = `${userData.firstName} ${userData.lastName}`;
};
listen(userData, ['firstName'], updateFullName);
listen(userData, ['lastName'], updateFullName);
// Create status display with any property changes
const statusDisplay = create('div', null, { className: 'status' }, (el) => {
listen(userData, [SYMBOL_ALL_PROPERTIES], () => {
el.textContent = `User: ${userData.fullName} | Age: ${userData.age} | Email: ${userData.email} | Active: ${userData.isActive ? 'Yes' : 'No'} | Last Login: ${userData.lastLogin.toLocaleTimeString()}`;
}, 1);
});
// Create controls
const userControls = create('div', null, { className: 'controls' }, (el) => [
create('div', el, { className: 'input-group' }, (group) => [
create('label', group, {}, () => ['First Name:']),
create('input', group, {
type: 'text',
value: userData.firstName,
oninput: (e) => userData.firstName = e.target.value
})
]),
create('div', el, { className: 'input-group' }, (group) => [
create('label', group, {}, () => ['Last Name:']),
create('input', group, {
type: 'text',
value: userData.lastName,
oninput: (e) => userData.lastName = e.target.value
})
]),
create('div', el, { className: 'input-group' }, (group) => [
create('label', group, {}, () => ['Age:']),
create('input', group, {
type: 'number',
value: userData.age,
oninput: (e) => userData.age = parseInt(e.target.value)
})
]),
create('div', el, { className: 'input-group' }, (group) => [
create('label', group, {}, () => ['Email:']),
create('input', group, {
type: 'text',
value: userData.email,
oninput: (e) => userData.email = e.target.value
})
]),
create('button', el, {
className: 'btn btn-success',
onclick: () => userData.isActive = !userData.isActive
}, () => ['Toggle Active']),
create('button', el, {
className: 'btn btn-primary',
onclick: () => userData.lastLogin = new Date()
}, () => ['Update Login Time'])
]);Try it live: http://localhost:3000/examples/listen.html
createHub(connections, inits)
Creates a hub for coordinating multiple data connections. Acts as a communication center that can trigger multiple functions when data changes, making it perfect for updating multiple UI elements or data structures simultaneously.
Parameters:
connections(Array): Array of functions that will be called when the hub is triggeredinits(Array): Array of initialization functions that determine when and how to call the hub's update function
Returns: An object with:
connections: Array of connection functionsu(...args): Update function that triggers all connections with the provided arguments
Example:
// Create reactive data
const data = createProxyObject({
count: 20,
total: 100,
});
// Create display
const counter = create('h2', null, {
id: 'counter'
}, () => []);
const progress = create('div', null, {
id: 'progress',
style: 'background-color: #007bff; height: 100%; width: 0%;'
}, () => []);
const progressBar = create('div', null, {
id: 'progress-bar',
style: 'border: 1px solid #000000; height: 40px; width: 100%;'
}, (el) => [
progress,
]);
const status = create('h2', null, { id: 'status' }, () => []);
const display = create('div', null, { className: 'status' }, (el) => [
counter,
progressBar,
status,
]);
// Create a hub for updating multiple UI elements
const hub = createHub([
// Update counter display
() => {
counter.textContent = `${data.count} / ${data.total} (${Math.round(data.count / data.total * 100)}%)`;
},
// Update progress bar
() => {
progress.style.width = `${data.count / data.total * 100}%`;
},
// Update progress bar color
() => {
progress.style.backgroundColor =
(data.count / data.total) > 1 ? '#28a745' :
(data.count / data.total) > .6 ? '#007bff' :
(data.count / data.total) > .3 ? '#ffc107' :
'#dc3545';
},
// Update status message
() => {
const percent = data.count / data.total;
const statusText = percent > .6 ? 'High' : percent > .3 ? 'Medium' : 'Low';
status.textContent = `Status: ${statusText}`;
}
], [
// Initialize so that when data.count changes, we notify the listeners in the hub
(update) => listen(data, [`count`], update),
(update) => listen(data, [`total`], update)
]);
// You can also call it manually
hub.u();Try it live: http://localhost:3000/examples/createHub.html
Lists and Repetition
repeat(init, childGenerator, scopeProperty, indexProperty)
Creates a reactive list of elements that efficiently updates when data changes. The function returns a nodeList containing comment anchors and dynamically generated child elements.
Parameters:
init(Function): Initialization function that receives three parameters:update(items, ev): Function to call when the list should be updated with new itemsremoveItem(item): Function to remove a specific item from the listinsertItem(item, index): Function to insert an item at a specific index
childGenerator(Function): Function that creates a DOM element for each item:(item, index) => elementscopeProperty(string): Property name to set on the element's scope (defaults to 'item')indexProperty(string): Property name to set the index on the element's scope (optional)
Example:
// Create data with an array
const data = createProxyObject({
items: createProxyObject([
{ name: 'Apple', color: 'red' },
{ name: 'Banana', color: 'yellow' },
{ name: 'Orange', color: 'orange' },
])
});
// Create item list using repeat
const itemList = create('div', null, { className: 'item-list' }, (el) => [
...repeat(
// Init function - listen for changes to items array, call update when items change
(update) => {
listen(data, ['items', SYMBOL_ALL_ITEMS], update, 1);
},
// Child generator function - creates each item
(item, index) => create('div', null, {
className: 'item',
style: `border-left: 4px solid ${item.color}`
}, (el) => [
create('div', el, { className: 'item-number' }, (el) => [
createText(index + 1, el, (n) => (
listen(n._, ['index'], (index = 0) => {
n.data = index + 1;
}, 1)
))
]),
create('div', el, { className: 'item-content' }, (el) => [
createText(item.name, el, (n) => (
listen(n._, ['item', 'name'], (name = '') => {
el.data = name;
}, 1)
))
]),
create('div', el, { className: 'item-actions' }, (el) => [
create('button', el, {
className: 'btn btn-danger',
style: 'padding: 5px 10px; font-size: 12px;',
}, (el) => (
el.addEventListener('click', () => {
const index = data.items.indexOf(el._.item);
if (index > -1) {
data.items.splice(index, 1);
}
}),
['Remove']
))
])
], {
item: item,
index: index,
}),
'item', // scope property name
'index' // index property name
)
]);
// Create controls
const controls = create('div', null, { className: 'controls' }, (el) => [
create('input', el, {
type: 'text',
placeholder: 'Add new item...',
onkeypress: (e) => {
if (e.key === 'Enter' && e.target.value.trim()) {
const colors = ['red', 'blue', 'green', 'purple', 'orange', 'pink'];
const newItem = createProxyObject({
name: e.target.value.trim(),
color: colors[Math.floor(Math.random() * colors.length)]
});
data.items.push(newItem);
e.target.value = '';
}
}
}),
create('button', el, {
className: 'btn btn-primary',
onclick: () => {
const colors = ['red', 'blue', 'green', 'purple', 'orange', 'pink'];
const items = ['Apple', 'Banana', 'Orange', 'Grape', 'Strawberry', 'Blueberry'];
const newItem = createProxyObject({
name: items[Math.floor(Math.random() * items.length)],
color: colors[Math.floor(Math.random() * colors.length)]
});
data.items.push(newItem);
}
}, () => ['Add Random Item'])
]);Try it live: http://localhost:3000/examples/repeat.html
Utility Functions
resolve(object, properties)
Resolves a property path on an object (e.g., resolve(obj, ['user', 'name'])).
Returns: The appropriate value based on the property path.
set(obj, propertyName, value, skipChange)
Sets a property value on an object and fires appropriate events.
Parameters:
obj(Object): The object to set the property onpropertyName(string): The name of the property to setvalue(any): The value to setskipChange(boolean): Whether to skip firing change events
Returns: The value that was set
deleteProperty(obj, propertyName, skipChange)
Deletes a property from an object and fires appropriate events.
Parameters:
obj(Object): The object to delete the property frompropertyName(string): The name of the property to deleteskipChange(boolean): Whether to skip firing change events
Returns: The result of the delete operation
DOM Utility Functions
The library also provides utility functions for common DOM operations:
append(parent, ...children)
Appends children to a parent element.
before(node, ...nodes)
Inserts nodes before a reference node.
after(node, ...nodes)
Inserts nodes after a reference node.
replaceWith(node, ...nodes)
Replaces a node with new nodes.
remove(node)
Removes a node from its parent.
setAttribute(node, attribute, value)
Sets an attribute on a node.
setAttributeNS(node, namespace, attribute, value)
Sets a namespaced attribute on a node.
ClassList Utility Functions
classListToggle(el, token, force)
Toggles a CSS class on an element.
classListAdd(el, ...args)
Adds CSS classes to an element.
classListRemove(el, ...args)
Removes CSS classes from an element.
classListReplace(el, oldToken, newToken)
Replaces one CSS class with another on an element.
EventTarget Utility Functions
addEventListener(target, type, fn, options)
Adds an event listener to a target.
removeEventListener(target, type, fn, options)
Removes an event listener from a target.
dispatchEvent(target, ev)
Dispatches an event on a target.
preventDefault(ev)
Prevents the default action of an event.
stopPropagation(ev)
Stops event propagation.
stopImmediatePropagation(ev)
Stops immediate event propagation.
Custom History Functions
back()
Goes back in browser history.
forward()
Goes forward in browser history.
go(index)
Goes to a specific position in browser history.
pushState(state, title, url)
Pushes a new state to browser history.
replaceState(state, title, url)
Replaces the current state in browser history.
link(linkElement, url)
Creates a navigation link for Single Page Applications (SPAs). Prevents the default link behavior and uses the custom history management to navigate to the specified URL. Should be used in conjuction with history utility functions.
Parameters:
linkElement(Element): The anchor (<a>) element to make into a navigation linkurl(string): The URL to navigate to when clicked
Example:
// Create navigation links for an SPA
const nav = create('nav', null, { className: 'navigation' }, (el) => [
create('ul', el, {}, (ul) => [
create('li', ul, {}, () => [
create('a', ul, { href: '/home' }, (el) => (
link(el, '/home'),
window.addEventListener('statechange', () => {
el.classList.toggle('active', window.location.pathname === '/home');
}),
['Home']
))
]),
create('li', ul, {}, () => [
create('a', ul, { href: '/about' }, (el) => (
link(el, '/about'),
window.addEventListener('statechange', () => {
el.classList.toggle('active', window.location.pathname === '/about');
}),
['About']
))
]),
create('li', ul, {}, () => [
create('a', ul, { href: '/contact' }, (el) => (
link(el, '/contact'),
window.addEventListener('statechange', () => {
el.classList.toggle('active', window.location.pathname === '/contact');
}),
['Contact']
))
])
])
]);
// Create page content
const pageContent = create('div', null, { className: 'page-content' }, (el) => [
create('div', null, {}, (el) => (
((stateChange) => (
window.addEventListener('statechange', stateChange),
stateChange()
))
(() => (
el.style.display = (
window.location.pathname === '/home' ||
window.location.pathname === '/examples/link.html'
) ? 'block' : 'none'
)),
[
create('h3', null, {}, () => ['Home Page']),
]
)),
create('div', null, {}, (el) => (
((stateChange) => (
window.addEventListener('statechange', stateChange),
stateChange()
))
(() => (
el.style.display = window.location.pathname === '/about' ? 'block' : 'none'
), 1),
[
create('h3', null, {}, () => ['About Page']),
]
)),
create('div', null, {}, (el) => (
((stateChange) => (
window.addEventListener('statechange', stateChange),
stateChange()
))
(() => (
el.style.display = window.location.pathname === '/contact' ? 'block' : 'none'
), 1),
[
create('h3', null, {}, () => ['Contact Page']),
]
)),
]);Try it live: http://localhost:3000/examples/link.html
Understanding Scope
Scope is a fundamental concept in Schematize UI.js that provides hierarchical data access and inheritance. Every element has a data scope accessible via element._ that can contain reactive data and functions.
1. Initializing Scope on Elements
You can initialize scope on an element by passing a scope object as the fifth parameter to the create function:
// Create an element with initial scope
const userData = { name: 'John', email: '[email protected]' };
const userCard = create('div', null, { className: 'user-card' }, (el) => [
create('h3', el, {}, () => ['User Profile'])
], userData); // <-- Scope object passed here
// Access the scope
console.log(userCard._.name); // 'John'
console.log(userCard._.email); // '[email protected]'2. Parent-Child Scope Inheritance
When you don't provide a scope object, child elements automatically reference their parent's scope:
// Parent with scope
const parentScope = { theme: 'dark', language: 'en' };
const parent = create('div', null, {}, (el) => [
create('h1', el, {}, () => ['Title']), // References parentScope
create('p', el, {}, () => ['Content']) // References parentScope
], parentScope);
// All children can access parent scope
const h1 = parent.querySelector('h1');
const p = parent.querySelector('p');
console.log(h1._.theme); // 'dark' (references the "theme" property of the _ scope from the parent div element)
console.log(p._.language); // 'en' (references the "language" property of the _ scope from the parent div element)3. Creating Child Scopes with Inheritance
When you provide a scope object to a child element, it creates a new scope that inherits from the parent's scope using prototypal inheritance:
// Parent scope
const parentScope = {
theme: 'dark',
language: 'en',
user: { name: 'John', role: 'admin' }
};
const parent = create('div', null, {}, (el) => [
// Child with its own scope that inherits from parent
create('div', el, {}, (childEl) => [
create('span', childEl, {}, () => ['Child content'])
], {
// Child's own properties
isActive: true,
count: 0,
// Override parent property
theme: 'light'
})
], parentScope);
const child = parent.querySelector('div > div');
const grandchild = parent.querySelector('span');
// Child scope properties
console.log(child._.isActive); // true (child's own property)
console.log(child._.count); // 0 (child's own property)
console.log(child._.theme); // 'light' (overrides parent's 'dark')
// Inherited properties from parent
console.log(child._.language); // 'en' (inherited from parent)
console.log(child._.user.name); // 'John' (inherited from parent)
// Grandchild inherits from child (which inherits from parent)
console.log(grandchild._.isActive); // true (inherited from child)
console.log(grandchild._.theme); // 'light' (inherited from child)
console.log(grandchild._.language); // 'en' (inherited from parent)4. Prototypal Inheritance in Action
The scope system uses JavaScript's prototypal inheritance, so you can access properties up the prototype chain:
// Create a hierarchy with different scope levels
const rootScope = {
appName: 'MyApp',
version: '1.0.0',
config: { apiUrl: 'https://api.example.com' }
};
const root = create('div', null, {}, (el) => [
create('header', el, {}, (headerEl) => [
create('h1', headerEl, {}, () => ['App Title'])
], {
// Header scope
title: 'Welcome',
showLogo: true
}),
create('main', el, {}, (mainEl) => [
create('section', mainEl, {}, (sectionEl) => [
create('p', sectionEl, {}, () => ['Content'])
], {
// Section scope
content: 'Main content',
isVisible: true
})
], {
// Main scope
currentPage: 'home',
user: { name: 'Jane', id: 123 }
})
], rootScope);
// Access properties at different levels
const h1 = root.querySelector('h1');
const p = root.querySelector('p');
// h1 can access: header scope + root scope
console.log(h1._.title); // 'Welcome' (header scope)
console.log(h1._.appName); // 'MyApp' (root scope)
console.log(h1._.config.apiUrl); // 'https://api.example.com' (root scope)
// p can access: section scope + main scope + root scope
console.log(p._.content); // 'Main content' (section scope)
console.log(p._.currentPage); // 'home' (main scope)
console.log(p._.user.name); // 'Jane' (main scope)
console.log(p._.version); // '1.0.0' (root scope)5. Accessing Parent Scope with _._
Within a child scope, you can directly access the parent's scope using _._ (the underscore property of the current scope):
// Parent scope
const parentScope = {
theme: 'dark',
language: 'en',
user: { name: 'John', role: 'admin' }
};
const parent = create('div', null, {}, (el) => [
create('div', el, {}, (childEl) => [
create('button', childEl, {
onclick: () => {
// Access current scope properties
console.log(childEl._.isActive); // true (current scope)
console.log(childEl._.count); // 0 (current scope)
// Access parent scope properties using _._
console.log(childEl._._.theme); // 'dark' (parent scope)
console.log(childEl._._.language); // 'en' (parent scope)
console.log(childEl._._.user.name); // 'John' (parent scope)
// You can also modify parent scope properties
childEl._._.theme = 'light'; // Changes parent's theme
childEl._._.user.role = 'user'; // Changes parent's user role
}
}, () => ['Access Parent Scope'])
], {
// Child scope
isActive: true,
count: 0
})
], parentScope);
// The _._ pattern is especially useful in event handlers and reactive functions
const reactiveChild = create('div', null, {}, (el) => [
createText('', el, (textNode) => {
// Listen to both current scope and parent scope
listen(el._, ['count'], () => {
textNode.data = `Child count: ${el._.count}, Parent theme: ${el._._.theme}`;
});
listen(el._._, ['theme'], () => {
textNode.data = `Child count: ${el._.count}, Parent theme: ${el._._.theme}`;
});
})
], { count: 0 });Practical Example - Component Communication:
// App-level scope
const appScope = {
currentUser: { name: 'John', permissions: ['read', 'write'] },
theme: 'dark',
notifications: []
};
const app = create('div', null, {}, (el) => [
// Header component
create('header', el, {}, (headerEl) => [
create('h1', headerEl, {}, () => ['My App']),
create('button', headerEl, {
onclick: () => {
// Access app-level data from header
console.log('Current user:', headerEl._._.currentUser.name);
console.log('Theme:', headerEl._._.theme);
// Add notification to app scope
headerEl._._.notifications.push({
message: 'Header button clicked',
timestamp: new Date()
});
}
}, () => ['Add Notification'])
], {
// Header-specific scope
isCollapsed: false
}),
// Main content component
create('main', el, {}, (mainEl) => [
create('div', mainEl, {}, (contentEl) => [
createText('', contentEl, (textNode) => {
// Display data from both current scope and parent scope
const updateContent = () => {
textNode.data = `Welcome ${contentEl._._.currentUser.name}! ` +
`Theme: ${contentEl._._.theme}, ` +
`Notifications: ${contentEl._._.notifications.length}`;
};
// Listen to changes in both scopes
listen(contentEl._, ['isVisible'], updateContent);
listen(contentEl._._, ['currentUser', 'theme', 'notifications'], updateContent);
updateContent();
})
])
], {
// Main-specific scope
isVisible: true,
currentPage: 'dashboard'
})
], appScope);Property Shadowing and Accessing Hidden Parent Properties:
// Parent scope with properties that might be shadowed
const parentScope = {
theme: 'dark',
user: { name: 'John', role: 'admin' },
count: 100,
settings: { notifications: true }
};
const parent = create('div', null, {}, (el) => [
create('div', el, {}, (childEl) => [
create('button', childEl, {
onclick: () => {
// Child scope shadows some parent properties
console.log('Child theme:', childEl._.theme); // 'light' (child's property)
console.log('Parent theme:', childEl._._.theme); // 'dark' (parent's property)
console.log('Child count:', childEl._.count); // 5 (child's property)
console.log('Parent count:', childEl._._.count); // 100 (parent's property)
// Non-shadowed properties are accessible normally
console.log('User name:', childEl._.user.name); // 'John' (inherited from parent)
console.log('Settings:', childEl._.settings); // { notifications: true } (inherited)
// You can modify the parent's shadowed property
childEl._._.theme = 'blue'; // Changes parent's theme to 'blue'
childEl._._.count = 200; // Changes parent's count to 200
// Child properties remain unchanged
console.log('Child theme after parent change:', childEl._.theme); // Still 'light'
console.log('Child count after parent change:', childEl._.count); // Still 5
}
}, () => ['Check Shadowed Properties'])
], {
// Child scope shadows parent properties with same names
theme: 'light', // Shadows parent's 'dark' theme
count: 5, // Shadows parent's 100 count
isActive: true // Child's own property
})
], parentScope);
// Practical example: Component with local state that shadows global state
const globalState = {
currentPage: 'home',
user: { name: 'Jane', isLoggedIn: true },
theme: 'dark',
notifications: []
};
const pageComponent = create('div', null, {}, (el) => [
create('h2', el, {}, () => ['Page Component']),
create('button', el, {
onclick: () => {
// Access local page state
console.log('Local page:', el._.currentPage); // 'dashboard' (local)
console.log('Local theme:', el._.theme); // 'light' (local)
// Access global state (shadowed by local)
console.log('Global page:', el._._.currentPage); // 'home' (global)
console.log('Global theme:', el._._.theme); // 'dark' (global)
// Access non-shadowed global properties
console.log('User:', el._.user.name); // 'Jane' (inherited)
console.log('Notifications:', el._.notifications); // [] (inherited)
// Update global state from component
el._._.currentPage = 'profile';
el._._.notifications.push('Page component updated global state');
}
}, () => ['Update Global State'])
], {
// Local component state that shadows global state
currentPage: 'dashboard', // Shadows global 'home'
theme: 'light', // Shadows global 'dark'
isLoading: false // Component-specific property
});6. Reactive Scope Updates
Scope properties can be reactive when using proxy objects:
// Create reactive scope
const reactiveScope = createProxyObject({
count: 0,
message: 'Hello',
items: ['apple', 'banana']
});
const container = create('div', null, {}, (el) => [
create('button', el, {}, (btnEl) => [
createText('Count: ', btnEl),
createText(reactiveScope.count.toString(), btnEl, (textNode) => {
// Listen for count changes and update text
listen(reactiveScope, ['count'], (newCount) => {
textNode.data = newCount.toString();
});
})
], {
// Child scope with reactive data
isClicked: false
})
], reactiveScope);
// Update reactive scope - all listening elements will update
reactiveScope.count = 5; // Button text updates to "Count: 5"
reactiveScope.message = 'Hi'; // Available to all child elements
reactiveScope.items.push('orange'); // Array changes trigger updatesExamples
Basic Counter App
Here's a simple counter application that demonstrates reactive data binding:
Try it live: http://localhost:3000/examples/basic-counter.html
import { create as c, listen, createProxyObject } from '@schematize/ui.js';
// Create reactive data using createProxyObject for proper reactivity
const data = createProxyObject({ count: 0 });
// Create the counter UI
const counter = c('div', null, { className: 'counter' }, (el) => [
c('h2', el, {}, () => [`Count: ${data.count}`]),
c('button', el, {
className: 'btn btn-primary',
onclick: () => data.count++
}, () => ['Increment']),
c('button', el, {
className: 'btn btn-secondary',
onclick: () => data.count--
}, () => ['Decrement'])
]);
// Listen for count changes and update the display
listen(data, ['count'], () => {
const h2 = counter.querySelector('h2');
h2.textContent = `Count: ${data.count}`;
});
// Append to document
document.body.appendChild(counter);Todo List with Conditional Rendering
This example shows how to create a todo list with conditional rendering:
Try it live: http://localhost:3000/examples/todo-list.html
import _set from '@schematize/instance.js/src/Instance/set.mjs';
import _append from '@schematize/instance.js/src/Collection/append.mjs';
import _replace from '@schematize/instance.js/src/Collection/replace.mjs';
import {
createText as ct,
creator,
listen,
createHub,
repeat,
SYMBOL_ALL_ITEMS,
SYMBOL_ALL_PROPERTIES,
classListToggle,
} from '@schematize/ui.js';
const button = creator('button');
const input = creator('input');
const form = creator('form');
const label = creator('label');
const ul = creator('ul');
const li = creator('li');
const div = creator('div');
const h1 = creator('h1');
const set = _set.call.bind(_set);
const Collection_append = _append.call.bind(_append);
const Collection_replace = _replace.call.bind(_replace);
const scope = {
todos: [
{ id: 1, text: 'Learn Schematize UI', completed: true },
{ id: 2, text: 'Build an app', completed: false },
{ id: 3, text: 'Share with community', completed: false }
],
filteredTodos: [],
newTodo: '',
filter: 'all' // 'all', 'active', 'completed'
};
// Create the main todo app
const todoApp =
div(null, { className: 'todo-app' }, (el) => (
// listen for any changes in the todos or filter
createHub([
() => {
set(el._, 'filteredTodos', el._.todos.filter(todo => {
if (el._.filter === 'active') return !todo.completed;
if (el._.filter === 'completed') return todo.completed;
return true;
}));
}
], [
(u) => listen(el._, [
'todos', [SYMBOL_ALL_ITEMS, 'change'], [SYMBOL_ALL_PROPERTIES, 'change']
], u),
(u) => listen(el._, ['filter'], u)
])
// update the filtered todos immediately
.u(),
[
h1(el, {}, () => ['Todo List']),
// Add new todo form
form(el, {}, (form) => (
form.addEventListener('submit', (e) => {
e.preventDefault();
let newTodo = el._.newTodo.trim();
if (newTodo) {
Collection_append(el._.todos, { id: Date.now(), text: newTodo, completed: false });
set(el._, 'newTodo', '');
}
}),
[
input(form, {
type: 'text',
placeholder: 'Add a new todo...',
}, (el) => (
el.addEventListener('input', (e) => set(el._, 'newTodo', e.target.value)),
listen(el._, ['newTodo'], (newTodo = '') => (el.value = newTodo), 1)
)),
button(form, { type: 'submit', className: 'btn btn-primary' }, () => ['Add'])
]
)),
// Filter buttons
div(el, { className: 'filters' }, (filters) => [
button(filters, { className: 'btn' }, (el) => (
el.addEventListener('click', () => set(el._, 'filter', 'all')),
listen(el._, ['filter'], filter => (
classListToggle(el, 'active', filter === 'all')
), 1),
['All']
)),
button(filters, { className: 'btn' }, (el) => (
el.addEventListener('click', () => set(el._, 'filter', 'active')),
listen(el._, ['filter'], filter => (
classListToggle(el, 'active', filter === 'active')
), 1),
['Active']
)),
button(filters, { className: 'btn' }, (el) => (
el.addEventListener('click', () => set(el._, 'filter', 'completed')),
listen(el._, ['filter'], filter => (
classListToggle(el, 'active', filter === 'completed')
), 1),
['Completed']
)),
]),
// Todo list
ul(el, { className: 'todo-list', id: 'todo-list' }, (ul, currentId = 0) => [
...repeat(
(u) => {
listen(ul._, ['filteredTodos'], u, 1);
},
(todo) => (
li(ul, { className: 'todo-item' }, (el, id = ++currentId) => (
listen(el._, ['todo', 'completed'], (completed) => (
classListToggle(el, 'completed', completed)
), 1),
[
input(el, {
id: id,
className: 'todo-checkbox',
type: 'checkbox',
checked: todo.completed,
}, (el) => (
el.addEventListener('change', () => set(el._.todo, 'completed', el.checked)),
listen(el._, ['todo', 'completed'], (completed) => (
el.checked = completed
), 1)
)),
label(el, { className: 'todo-text', for: id }, (el) => [
ct(todo.text, el, (node) => (
listen(el._, ['todo', 'text'], text => node.data = text, 1)
)),
]),
button(el, { className: 'delete-btn' }, (el) => (
el.addEventListener('click', () => {
const index = el._.todos.findIndex(t => t.id === el._.todo.id);
Collection_replace(el._.todos, index, 1);
}),
['×']
))
]
), {
todo: todo,
})
),
'todo'
)
])
]
), scope);
// append to document
document.body.appendChild(todoApp);Form with Two-Way Data Binding
This example demonstrates two-way data binding with form inputs:
Try it live: http://localhost:3000/examples/form-binding.html
import {
create as c,
listen,
createProxyObject,
SYMBOL_ALL_PROPERTIES
} from '@schematize/ui.js';
// Form data
const formData = createProxyObject({
name: '',
email: '',
age: 25,
newsletter: true
});
// Create form with bound inputs
const form = c('form', null, { className: 'user-form' }, (el) => [
c('h2', el, {}, () => ['User Registration']),
// Name input
c('div', el, { className: 'form-group' }, (group) => [
c('label', group, { for: 'name' }, () => ['Name:']),
c('input', group, {
type: 'text',
value: formData.name,
oninput: (e) => formData.name = e.target.value
})
]),
// Email input
c('div', el, { className: 'form-group' }, (group) => [
c('label', group, { for: 'email' }, () => ['Email:']),
c('input', group, {
type: 'email',
value: formData.email,
oninput: (e) => formData.email = e.target.value
})
]),
// Age input
c('div', el, { className: 'form-group' }, (group) => [
c('label', group, { for: 'age' }, () => ['Age:']),
c('input', group, {
type: 'number',
value: formData.age,
oninput: (e) => formData.age = parseInt(e.target.value)
})
]),
// Newsletter checkbox
c('div', el, { className: 'form-group' }, (group) => [
c('label', group, {}, () => [
c('input', group, {
type: 'checkbox',
checked: formData.newsletter,
onchange: (e) => formData.newsletter = e.target.checked
}),
' Subscribe to newsletter'
])
]),
// Submit button
c('button', el, {
type: 'submit',
className: 'btn btn-primary'
}, () => ['Submit'])
]);
// Display current form data
const dataDisplay = c('div', null, { className: 'data-display' }, (el) => [
c('h3', el, {}, () => ['Current Form Data:']),
c('pre', el, {}, (el) => (
listen(formData, [[SYMBOL_ALL_PROPERTIES, 'change']], () => (
el.textContent = `{\n${
['name', 'email', 'age', 'newsletter']
.map((key) => ` ${key}: ${JSON.stringify(formData[key])}`)
.join(',\n')
}\n}`
), 1),
[]
))
]);
// Handle form submission
form.addEventListener('submit', (e) => {
e.preventDefault();
console.log('Form submitted:', formData);
alert('Form submitted! Check console for data.');
});
// Append to document
document.body.appendChild(form);
document.body.appendChild(dataDisplay);SVG Graphics Example
This example shows how to create interactive SVG graphics:
Try it live: http://localhost:3000/examples/svg-graphics.html
import {
create as c,
createSVG as svg,
listen,
repeat,
createProxyObject,
SYMBOL_ALL_PROPERTIES
} from '@schematize/ui.js';
// SVG data
const data = createProxyObject({
circles: createProxyObject([
createProxyObject({ id: 1, cx: 100, cy: 100, r: 30, fill: 'red' }),
createProxyObject({ id: 2, cx: 200, cy: 150, r: 25, fill: 'blue' }),
createProxyObject({ id: 3, cx: 150, cy: 200, r: 35, fill: 'green' }),
]),
selected: null
});
// Instructions
const instructions = c('div', null, { className: 'instructions' }, (el) => [
c('h4', el, {}, () => ['Instructions:']),
c('ul', el, {}, () => [
c('li', el, {}, () => ['Click on circles to select them (they will get a black border)']),
c('li', el, {}, () => ['Click "Add Random Circle" to add new circles']),
c('li', el, {}, () => ['Click "Delete Selected" to remove the selected circle']),
c('li', el, {}, () => ['All changes are reactive and update automatically'])
])
]);
// Create SVG canvas
const svgCanvas = svg('svg', null, {
width: 400,
height: 300,
viewBox: '0 0 400 300',
style: 'border: 1px solid #ccc;'
}, (svgEl) => [
// Background
svg('rect', svgEl, {
width: 400,
height: 300,
fill: '#f0f0f0'
}),
// Circles
...repeat(
(update) => listen(data, ['circles', SYMBOL_ALL_PROPERTIES], (circles) => update(circles), 1),
(circle) => {
return svg('circle', svgEl, {
stroke: 'none',
'stroke-width': 2,
style: 'cursor: pointer;',
}, (el) => (
// when selected changes or the circle changes, update the class
((u) => (
listen(data, ['selected'], u),
listen(el._, ['circle'], u),
u()
))
(() => {
// stroke if selected
el.setAttribute('stroke', data.selected === el._.circle ? 'black' : 'none');
// assign properties to the element
el._.circle &&
['cx', 'cy', 'r', 'fill'].forEach(name => {
el.setAttribute(name, el._.circle[name]);
});
}),
// when the circle is clicked, set the selected to the circle
el.addEventListener('click', () => data.selected = el._.circle),
[]
), {
circle: circle
});
},
'circle',
),
]);
// Control panel
const controls = c('div', null, { className: 'svg-controls' }, (el) => [
c('h3', el, {}, () => ['SVG Controls']),
c('button', el, {
className: 'btn btn-primary',
onclick: () => {
const newCircle = createProxyObject({
id: Date.now(),
cx: Math.random() * 300 + 50,
cy: Math.random() * 200 + 50,
r: Math.random() * 20 + 15,
fill: ['red', 'blue', 'green', 'yellow', 'purple'][Math.floor(Math.random() * 5)]
});
data.circles.push(newCircle);
}
}, () => ['Add Random Circle']),
c('button', el, {
className: 'btn btn-danger',
onclick: () => {
if (data.selected) {
const index = data.circles.indexOf(data.selected);
if (index !== -1) {
data.circles.splice(index, 1);
data.selected = null;
}
}
}
}, () => ['Delete Selected'])
]);
// Append to document
document.body.appendChild(instructions);
document.body.appendChild(svgCanvas);
document.body.appendChild(controls);Advanced Features
Custom History Management
The library includes custom history management functions:
import { pushState, replaceState, back, forward, go } from '@schematize/ui.js';
// Push a new state
pushState({ page: 'home' }, 'Home', '/home');
// Replace current state
replaceState({ page: 'about' }, 'About', '/about');
// Navigate back/forward
back();
forward();
go(-2); // Go back 2 stepsEvent Target Interface
All elements support the EventTarget interface:
import { create as c } from '@schematize/ui.js';
const element = c('div', null, {}, () => []);
// Add event listeners
element.addEventListener('customEvent', (e) => {
console.log('Custom event received:', e.detail);
});
// Dispatch custom events
element.dispatchEvent(new CustomEvent('customEvent', {
detail: { message: 'Hello from custom event!' }
}));Browser Support
Schematize UI.js works in all modern browsers that support:
- ES6 Proxies
- ES6 Classes
- ES6 Arrow Functions
- ES6 Template Literals
This includes:
- Chrome 49+
- Firefox 18+
- Safari 10+
- Edge 12+
License
MIT License - see LICENSE file for details.
Want to explore further?
- Check out the examples in the
./examples/directory - Look at the source code in
./src/to understand how things work - The library is intentionally simple and transparent - you can read and understand the entire codebase
