Initial commit
This commit is contained in:
132
README.md
Normal file
132
README.md
Normal 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
77
date.js
Normal 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
95
dialog.css
Normal 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
187
dialog.js
Normal 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
106
dom.js
Normal 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
80
index.html
Normal 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
143
json.js
Normal 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
62
jsonform.css
Normal 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
228
jsonform.js
Normal 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,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user