Initial commit

This commit is contained in:
2025-09-20 22:59:18 +02:00
commit 6ba8860de9
9 changed files with 1110 additions and 0 deletions

132
README.md Normal file
View File

@@ -0,0 +1,132 @@
# NoccyLabs JSL
This is a collection of libraries that make working with JavaScript and data.
Each component is available in it's own file, and example/default stylesheets are available in separate files.
## date.js
**WORK IN PROGRESS!**
This component makes working with dates less of a pain.
```javascript
import { date, Timestamp } from "./date.js";
// now
const now = date();
// nothing below here in this example works, to be implemented!
// tomorrow
const tomorrow = date().add({days:1});
// yesterday
const yesterday = now.sub({ days:1 });
// sometime
const custom = date("2025-09-01 15:00:42 +02:00");
// format dates, php style
let ts = now.format("Y-m-d H:i:s P");
```
## dialog.js
This component creates dialogs on the fly, and resolves a promise when the dialog is accepted.
```javascript
import { dialog } from "./dialog.js";
// show a message box
dialog.msgbox("Something");
// show a message box and do something when dismissed
dialog.msgbox("Ready!").then(
function (result) {
// do something!
}
);
// show a confirmation in an async callback
... async () => {
const result = await dialog.confirm("Are you sure about that?");
if (result === "ok") {
// do it
}
}
// show custom actions in a custom select style dialog
dialog.select("Put in warehouse", "Select", { "none":"None", "1":"WH1", "2":"WH2" }).then(...)
```
## dom.js
This file offers `dom` and `el` that can be used to manipulate the DOM.
```javascript
import { dom, el } from "./dom.js";
// build DOM
const myDiv = el.div({ class: "form-row" }, [
el.label({ class:"form-label", for:"myInput" }, "Label"),
el.input({ class:"form-control", id:"myInput", type:"text" })
]);
// update element attributes
dom.apply(myDiv, { style:"font-weight:bold;" });
// append children
dom.append(myDiv, [
"Some text",
el.span()
]);
```
## json.js
This component contains tools to query and update data structures using simplified JSON paths.
```javascript
import { jsonQuery, jsonPatch } from './json.js';
let model = {
name: "Bob",
info: {
age: 42
}
};
jsonQuery(model, ".name") // → "Bob"
jsonPatch(model, ".name", "Bobby"); // → { name:"Bobby", info:{ age:42 }}
let items = [
{ "name": "cheese", "price": "5.99" },
{ "name": "wine", "price": "2.99" }
];
jsonQuery(items, "[].name") // → [ "cheese", "wine" ]
```
## jsonform.js
This component not only builds forms and allow you to map it against paths in a model dataset, but it also allows you to do inline editing of your data, with type mapping.
```javascript
import { JsonForm, TextField } from "./jsonform.js";
const productForm = new JsonForm();
productForm.addRow()
.append(new TextField({ label:"Product name", path:".product.name", width:70 })
.append({ label:"Price", path:".price" });
myTargetEl.appendChild(productForm.dom());
productForm.model = {
product: {
name: "Swedish Fish"
},
price: "9.99"
};
```

77
date.js Normal file
View File

@@ -0,0 +1,77 @@
class Timestamp {
DEFAULT_FORMAT = "Y-m-d H:i:s P";
#date;
constructor(init, options = {}) {
if (init === 'now') {
this.#date = new Date();
} else if (init && init.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2}) (.+?)$/)) {
let { year, month, day, hour, minute, second, timezone } = init.replace('-',' ').split();
let h = null, m = null;
if (timezone.indexOf(':') !== false) {
let { h, m } = timezone.split(':');
} else {
h = timezone;
}
this.#date = new Date(year, month, day, hour, minute, second, 0);
h = parseInt(h);
m = parseInt(m);
let t = this.#date.getTime();
let d = ((h * 3600) + (m * 60) * 1000);
this.#date.setTime(t -d );
}
}
get date() {
return this.#date;
}
get time() {
return this.format('H:i:s');
}
set time(time) {
}
add(periods) {
// return new
}
sub(periods) {
// return new
}
time(time) {
// return new
}
diff(other) {
// return diff
}
format(format) {
// format date
}
toString() {
return this.format(this.DEFAULT_FORMAT);
}
}
function date(when = "now") {
return new Timestamp(when);
}
export {
Timestamp,
date
};

95
dialog.css Normal file
View File

@@ -0,0 +1,95 @@
@property --jsl-dialog-bgcolor {
syntax: "<color>";
inherits: true;
initial-value: #fff;
}
@property --jsl-dialog-bordercolor {
syntax: "<color>";
inherits: true;
initial-value: #444;
}
.jsl-dialog {
background: var(--jsl-dialog-bgcolor);
box-shadow: 0px 5px 15px rgba(0,0,0,0.5);
max-width: min(90vw, 500px);
border: solid 2px var(--jsl-dialog-bordercolor);
border-radius: 0.5rem;
padding: 0px;
font-family: system-ui;
&.-style-msgbox, &.-style-info {
--jsl-dialog-bgcolor: #dfd;
--jsl-dialog-bordercolor: #1a8310;
.-title::before {
content: '🛈 ';
}
}
&.-style-confirm {
--jsl-dialog-bgcolor: #ffd;
--jsl-dialog-bordercolor: #835f10;
.-title::before {
content: '✔ ';
}
}
&.-style-error {
--jsl-dialog-bgcolor: #fcc;
--jsl-dialog-bordercolor: #bb3434;
.-title::before {
content: '⚠️ ';
}
}
&.-style-warning {
--jsl-dialog-bgcolor: #ffc;
--jsl-dialog-bordercolor: #c93b03;
.-title::before {
content: '⚠️ ';
}
}
.-header {
border-top-left-radius: 0.5rem;
border-top-right-radius: 0.5rem;
/* background: rgba(0, 0, 0, 0.2); */
display: flex;
padding: 0.5rem;
}
.-title {
font-weight: bold;
flex-grow: 1;
}
.-close-btn {
border: 0px;
background: transparent;
color: red;
}
.-content {
padding: 0.5rem;
}
.-buttons {
padding: 0.5rem;
padding-top: 0.2rem;
text-align: center;
.action {
border: solid 1px #666;
border-radius: 0.25rem;
padding: 0.35rem 0.6rem;
margin: 0px 0.25rem;
background: #eee;
&.default-action {
outline: solid 1px #666;
}
&:active {
outline: solid 1px #444;
}
}
}
}

187
dialog.js Normal file
View File

@@ -0,0 +1,187 @@
/*
* This file is part of NoccyLabs JavaScript Standard Library (JSL)
* Copyright (c) 2025, NoccyLabs. Licensed under GNU GPL v2 or later
*
*
*/
import { dom, el } from './dom.js';
/**
* A friendly user dialog class
*
*/
class UserDialog {
#dialog;
#resolver;
#style = "msgbox";
#title = "MessageBox";
#message;
#actions = { "ok":"Ok" };
/**
* Dialog constructor
*
* @param {string} style The dialog style
* @param {string} title The dialog title
* @param {string} message The dialog message
* @param {object} actions The dialog actions
*/
constructor(style, title, message, actions) {
if (style) this.#style = style;
if (title) this.#title = title;
if (message) this.#message = message;
if (actions && typeof actions == 'object') this.#actions = actions;
}
/**
* Build and show the dialog. Promise will resolve with the selected action key,
* or false if closed via the X.
*
* @returns Promise
*/
show() {
let promise = new Promise((resolve) => {
this.#resolver = resolve;
});
this.#buildDialog();
this.#showDialog();
return promise;
}
#buildDialog() {
if (this.#dialog)
throw new Error("#buildDialog should not be called with an active #dialog");
const dialog = el.dialog({ class: 'jsl-dialog' });
dialog.addEventListener('close', event => {
if (this.#resolver) {
this.#resolver(false);
this.#resolver = null;
}
this.#destroyDialog();
});
dialog.className += ' -style-' + this.#style;
let cbtn;
dom.append(dialog, el.div({ class: '-header' }, [
el.div({ class: '-title' }, this.#title),
// cbtn = el.button({ class: '-close-btn' }, '✕')
]));
dom.append(dialog, el.div({ class: '-content' }, this.#message));
// cbtn.addEventListener('click', () => { this.#handleButton(false); });
const buttons = el.div({ class: '-buttons' });
let tabindex = 1;
Object.keys(this.#actions).forEach(action => {
let btn;
dom.append(buttons, btn = el.button({ class: 'action', tabIndex: tabindex++ }, this.#actions[action]));
// if (action.match(/\*$/)) {
// btn.style.outline = 'solid 1px #222';
// btn.class += ' default-action';
// }
btn.addEventListener('click', () => { this.#handleButton(action); });
});
dom.append(dialog, buttons);
this.#dialog = dialog;
}
#destroyDialog() {
document.body.removeChild(this.#dialog);
this.#dialog = null;
}
#showDialog() {
if (!this.#dialog)
throw new Error("#showDialog should be called after #buildDialog with an active #dialog");
document.body.appendChild(this.#dialog);
this.#dialog.showModal();
setTimeout(() => {
let act = this.#dialog.querySelector('button.default-action');
if (act) { act.focus(); return; }
act = this.#dialog.querySelector('button.action');
if (!act) { console.warning("No buttons found? What to focus?"); return; }
act.focus();
}, 200);
}
#handleButton(id) {
this.#resolver(id);
this.#resolver = null;
this.#destroyDialog();
}
}
class UserDialogFactory {
/**
* Create a dialog, and return a promise that will resolve with the key of the
* selected button, or false if closed via the X.
*
* @param {string} type The dialog type, ex msgbox, information, error
* @param {string} title The dialog title
* @param {string} message The message to display
* @param {object} actions The buttons to present at the bottom of the dialog
* @returns Promise
*/
showDialog(type, title, message, actions) {
let dialog;
switch (type) {
case 'msgbox':
title = title ?? "MessageBox";
actions = actions ?? { 'ok': 'Ok' };
dialog = new UserDialog('msgbox', title, message, actions);
break;
case 'information':
case 'info':
title = title ?? "Information";
actions = actions ?? { 'ok': 'Ok' };
dialog = new UserDialog('info', title, message, actions);
break;
case 'error':
title = title ?? "Error";
actions = actions ?? { 'ok': 'Ok' };
dialog = new UserDialog('error', title, message, actions);
break;
case 'warning':
title = title ?? "Warning";
actions = actions ?? { 'ok': 'Ok' };
dialog = new UserDialog('warning', title, message, actions);
break;
case 'confirm':
title = title ?? "Confirmation";
actions = actions ?? { 'ok': 'Ok', 'cancel': 'Cancel' };
dialog = new UserDialog('confirm', title, message, actions);
break;
default:
title = title ?? "Dialog";
actions = actions ?? { 'ok': 'Ok' };
dialog = new UserDialog(type, title, message, actions);
}
return dialog.show();
}
}
// The dialog factory
const factory = new UserDialogFactory();
// A proxy to invoke various types via dialog.
const dialog = new Proxy(factory, {
get(target, name) {
return name in target
? target[name]
: (message, title = null, actions = null) => target.showDialog(name, title, message, actions);
}
});
export {
factory,
dialog,
UserDialog
};

106
dom.js Normal file
View File

@@ -0,0 +1,106 @@
/*
* This file is part of NoccyLabs JavaScript Standard Library (JSL)
* Copyright (c) 2025, NoccyLabs. Licensed under GNU GPL v2 or later
*
*
*/
/**
* A helper class to create (and manipulate) elements in the DOM.
*
*/
class DomHelper {
/**
* Create a new element without all the fuzz.
*
* @param {string} tag The name of the HTML tag
* @param {object} attr Map of attributes to apply to the element
* @param {array|Element|string} children Children or single child/text to apply
* @returns Element
*/
createEl(tag, attr = {}, children = []) {
//console.debug(`create: ${tag}`, attr, children);
let el = document.createElement(tag);
this.apply(el, attr);
this.append(el, children);
return el;
};
/**
* Apply attributes from an object to an element
*
* @param {Element} tag The element to modify
* @param {object} attr Key-value pairs of attributes to apply, or if null remove
*/
apply(tag, attr) {
Object.keys(attr).forEach(key => {
if (key.match(/^on:/)) {
const ev = key.substring(3);
tag.addEventListener(ev, attr[key]);
return;
}
if (attr[key] === null) {
tag.removeAttribute(key);
} else {
tag.setAttribute(key, attr[key]);
}
});
}
/**
* Append one or more children or text nodes to an element.
*
* @param {Element} tag The element to modify
* @param {array|Element|string} children List of elements or single element to append to the element
* @returns
*/
append(tag, children) {
//console.debug(`append: ${tag.nodeName}`, children);
if (typeof children == 'object' && children instanceof Element) {
//console.debug(`-> appended element`);
tag.appendChild(children);
return;
}
if (typeof children == 'string') {
//console.debug(`-> appended string`);
let n = document.createTextNode(children);
tag.appendChild(n);
return;
}
if (Array.isArray(children)) {
children.forEach(child => {
this.append(tag, child);
});
//console.debug(`-> appended nested`);
}
}
/**
* Apply templated values to a string
*
* @param {string} str The string to perform interpolation on
* @param {object} tpl A string of key-value pair to replace between percent signs
* @returns string
*/
interpolate(str, tpl) {
Object.keys(tpl).forEach(key => {
str = str.replace(`%${key}%`, tpl[key]);
});
return str;
}
};
// The DOM helper
const dom = new DomHelper();
// Proxy for creating any supported HTML element via el.TAG
const el = new Proxy(dom, {
get(target,name) {
return name in target
? target[name]
: (attr = {}, children = []) => target.createEl(name, attr, children);
}
});
export { dom, el };

80
index.html Normal file
View File

@@ -0,0 +1,80 @@
<!DOCTYPE HTML>
<html>
<head>
<link rel="stylesheet" type="text/css" href="./dialog.css">
<link rel="stylesheet" type="text/css" href="./jsonform.css">
</head>
<body>
<script type="module">
import { el } from './dom.js';
import { dialog } from './dialog.js';
import { JsonForm, TextField, SelectField } from './jsonform.js';
import { jsonQuery, jsonPatch, tokenizePath } from './json.js';
import { date } from './date.js';
// build our dom
let btn, btn2, btn3;
let root = el.div({}, [
el.div({ style:'color:#444;' }, "This is dynamically generated!"),
el.div({ style:'display:flex; gap:0.25rem; margin-top:0.25rem;' }, [
el.button({ 'on:click': () => dialog.info("You clicked me!") }, "With event"),
btn = el.button({}, "Hello World"),
btn2 = el.button({}, "Error mode"),
btn3 = el.button({}, "Edit form"),
])
]);
// click handler
btn.addEventListener('click', () => {
dialog.msgbox("Yo! This is a messagebox!").then((result) => {
console.log("Accepted with: " + result);
dialog.confirm("Isn't that neat?", "Woah", { "sure":"Sure!", "nah":"Nah..." }).then((result) => {
console.log("Neat? " + result);
});
});
});
// click handler
btn2.addEventListener('click', async () => {
await dialog.error("Oh shit. That didn't work. Maybe try again?");
await dialog.information("Just kidding! Everything is great!");
});
document.body.appendChild(root);
const userForm = new JsonForm();
userForm.layout.addRow()
.append(new TextField({ label:"Username", width:70, path:".username" }))
.append(new TextField({ label:"ID", path:".id", locked:true }));
userForm.layout.addRow()
.append(new TextField({ label:"Created", width:50, path:".meta.created", locked:true }))
.append(new TextField({ label:"Updated", width:50, path:".meta.updated", locked:true }));
document.body.appendChild(userForm.dom());
// Set the model
userForm.model = {
username: "Admin",
id: 1,
meta: {
created: '2025-09-01 00:00:00 +02:00',
}
};
btn3.addEventListener('click', () => {
userForm.editable = !userForm.editable;
});
// console.log(tokenizePath(".foo.bar[1].title"));
// console.log(jsonQuery({ "foo":{ "bar":"42" }}, ".foo.bar" ));
// console.log(jsonQuery([ { name:"cheese" }, { name:"bread" }, { name:"milk" } ], "[].name" ));
let data = { name: "Bob" };
console.log(data);
let data2 = jsonPatch(data, ".name", "Bobby");
console.log(data, data2);
</script>
</body>

143
json.js Normal file
View File

@@ -0,0 +1,143 @@
function jsonQuery(json, path, defaultValue = null) {
let toks = tokenizePath(path);
let ptr = json;
let search = null;
let searchTok = null;
let multiResult = null;
let tok;
while (tok = toks.shift()) {
switch (tok) {
case ".": // find key
search = 'key';
break;
case "[": // find array index
search = "array";
break;
case "]": // find
// if searchTok is "", match all items
if (search !== "array") {
throw new Error("Invalid path");
}
if (searchTok === null) {
if (multiResult !== null) {
throw new Error("Can't match full arrays on multiple levels");
}
multiResult = [];
ptr.forEach(item => {
let res = jsonQuery(item, toks.join(""));
multiResult.push(res);
});
return multiResult;
}
if (typeof ptr[searchTok] === 'undefined') {
return defaultValue;
}
ptr = ptr[searchTok];
break;
default:
if (search === 'key') {
// find key tok in ptr
if (typeof ptr[tok] === 'undefined') {
return defaultValue;
} else {
ptr = ptr[tok];
}
} else if (search === 'array') {
// find index tok in ptr after finding a ]
searchTok = tok;
}
}
}
return ptr;
}
function jsonPatch(json, path, value) {
let toks = tokenizePath(path);
json = structuredClone(json);
let ptr = json;
let search = null;
let searchTok = null;
let multiResult = null;
let tok;
while (tok = toks.shift()) {
switch (tok) {
case ".": // find key
search = 'key';
break;
case "[": // find array index
search = "array";
break;
case "]": // find
// if searchTok is "", match all items
if (search !== "array") {
throw new Error("Invalid path");
}
if (searchTok === null) {
throw new Error("Can't patch arrays yet");
}
if (typeof ptr[searchTok] === 'undefined') {
return json;
}
ptr = ptr[searchTok];
break;
default:
if (search === 'key') {
// find key tok in ptr
if (typeof ptr[tok] === 'undefined') {
return defaultValue;
} else {
if (toks.length === 0) {
ptr[tok] = value;
return json;
} else {
ptr = ptr[tok];
}
}
} else if (search === 'array') {
// find index tok in ptr after finding a ]
searchTok = tok;
}
}
}
// ptr = value;
return json;
}
function tokenizePath(path) {
let t = [];
let b = null;
for (let p = 0; p < path.length; p++) {
let c = path[p];
if (c == ".") {
if (b !== null) t.push(b);
b = null;
t.push(".");
} else if (c == "[") {
if (b !== null) t.push(b);
b = null;
t.push("[");
} else if (c == "]") {
if (b !== null) t.push(b);
b = null;
t.push("]");
} else {
b = ((b===null)?"":b) + c;
}
}
if (b !== null) t.push(b);
return t;
}
export {
tokenizePath,
jsonQuery,
jsonPatch,
};

62
jsonform.css Normal file
View File

@@ -0,0 +1,62 @@
@property --jsl-form-text-default {
syntax: "<color>";
inherits: true;
initial-value: #444;
}
@property --jsl-form-text-editable {
syntax: "<color>";
inherits: true;
initial-value: #000;
}
@property --jsl-form-default-outline {
syntax: "<color>";
inherits: true;
initial-value: #ccc;
}
@property --jsl-form-editable-outline {
syntax: "<color>";
inherits: true;
initial-value: #6666b1;
}
@property --jsl-form-focused-outline {
syntax: "<color>";
inherits: true;
initial-value: #290ada;
}
.jsl-form {
.-row {
display: flex;
gap: 0.5rem;
box-sizing: border-box;
.-form-field {
display: inline-block;
font-family: system-ui;
.-label {
font-size: 90%;
display: block;
padding: 0.25rem 0.5rem;
color: #444;
}
.-field {
font-size: 120%;
color: var(--jsl-form-text-default);
box-shadow: inset 5px 5px 20px -5px rgba(0,0,0,0.1);
display: block;
border: solid 1px var(--jsl-form-default-outline);
border-radius: 0.25rem;
padding: 0.25rem 0.5rem;
line-height: 1.5rem;
min-height: 1.5rem;
&[contenteditable] {
border: solid 1px var(--jsl-form-editable-outline);
color: var(--jsl-form-text-editable);
&:focus {
border: solid 1px var(--jsl-form-focused-outline);
}
}
}
}
}
}

228
jsonform.js Normal file
View File

@@ -0,0 +1,228 @@
import { dom, el } from './dom.js';
import { jsonQuery } from './json.js';
/**
* This form encapsulates a form with a model and a layout.
*
*/
class JsonForm {
#dom = null;
#model = null;
#layout = null;
#editable = false;
set model(model) {
this.#model = model;
this.#layout.modelUpdated(model);
}
get model() {
return this.#model;
}
set editable(state) {
this.#editable = !!state;
this.#layout.setEditable(this.#editable);
if (this.#editable === false) {
this.refreshModel();
}
}
get editable() {
return this.#editable;
}
get layout() {
return this.#layout;
}
constructor() {
this.#layout = new FormLayout();
}
dom() {
if (!this.#dom) {
this.#dom = el.div({ class: 'jsl-form' });
}
this.#layout.buildDom(this.#dom);
return this.#dom;
}
refreshModel() {
// TODO go over the fields and update the model with any modified data
}
}
class FormLayout {
#rows = [];
addRow() {
const row = new FormRow();
this.#rows.push(row);
return row;
}
buildDom(element) {
element.innerHTML = '';
this.#rows.forEach(row => {
const rowEl = row.dom();
element.appendChild(rowEl);
})
}
modelUpdated(model) {
this.#rows.forEach(row => row.modelUpdated(model));
}
setEditable(state) {
this.#rows.forEach(row => row.setEditable(state));
}
}
class FormRow {
el = null;
#fields = [];
append(field) {
this.#fields.push(field);
return this;
}
dom() {
if (!this.el) {
this.el = el.div({ class: '-row' });
}
this.el.innerHTML = '';
this.#fields.forEach(field => {
this.el.appendChild(field.dom());
})
return this.el;
}
modelUpdated(model) {
this.#fields.forEach(field => {
if (!field.options.path) return;
field.value = jsonQuery(model, field.options.path);
});
}
setEditable(state) {
this.#fields.forEach(field => {
if (field.options.locked === true) return;
field.setEditable(state)
});
}
}
class FormField {
#el = null;
#label = null;
#options = {};
#value = null;
get value() {
return this.#value;
}
set value(val) {
this.#value = val;
this.valueUpdated();
}
get el() {
return this.#el;
}
get options() {
return this.#options;
}
constructor(options = {}) {
this.#options = options || {};
}
dom() {
if (!this.#el) {
let style = null;
if (this.#options.width) {
style = 'width: ' + this.#options.width + '%;';
} else {
style = 'flex-grow: 1;';
}
this.#el = el.div({ class: '-form-field', style: style ?? null });
this.#label = el.label({ class: '-label' });
this.#label.innerHTML = this.options.label ?? "Unlabeled";
this.#el.appendChild(this.#label);
}
this.buildField();
return this.#el;
}
buildField() {
}
valueUpdated() {
}
setEditable(state) {
}
}
class TextField extends FormField {
#input = null;
buildField() {
if (!this.#input) {
this.#input = el.div({ class: '-field -text-field' })
this.el.appendChild(this.#input);
}
this.#input.innerText = this.value;
}
valueUpdated() {
this.#input.innerText = this.value;
}
getValue() {
return this.#input.innerText;
}
setEditable(state) {
if (state) {
dom.apply(this.#input, { 'contenteditable':'plaintext-only' });
} else {
dom.apply(this.#input, { 'contenteditable':null });
}
}
}
class DateField extends FormField {
#input = null;
buildField() {
if (!this.#input) {
this.#input = el.div({ class: '-field -date-field' })
this.el.appendChild(this.#input);
}
this.#input.innerHTML = "I'm a date!";
}
}
class SelectField extends FormField {
}
class CheckField extends FormField {
}
export {
JsonForm,
FormLayout,
FormRow,
FormField,
TextField,
DateField,
SelectField,
CheckField,
}