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