From 6ba8860de91e18f01f7f9e4c0499ce174be1f0ad Mon Sep 17 00:00:00 2001 From: Christopher Vagnetoft Date: Sat, 20 Sep 2025 22:59:18 +0200 Subject: [PATCH] Initial commit --- README.md | 132 +++++++++++++++++++++++++++++ date.js | 77 +++++++++++++++++ dialog.css | 95 +++++++++++++++++++++ dialog.js | 187 ++++++++++++++++++++++++++++++++++++++++++ dom.js | 106 ++++++++++++++++++++++++ index.html | 80 ++++++++++++++++++ json.js | 143 ++++++++++++++++++++++++++++++++ jsonform.css | 62 ++++++++++++++ jsonform.js | 228 +++++++++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 1110 insertions(+) create mode 100644 README.md create mode 100644 date.js create mode 100644 dialog.css create mode 100644 dialog.js create mode 100644 dom.js create mode 100644 index.html create mode 100644 json.js create mode 100644 jsonform.css create mode 100644 jsonform.js diff --git a/README.md b/README.md new file mode 100644 index 0000000..853bf68 --- /dev/null +++ b/README.md @@ -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" +}; +``` diff --git a/date.js b/date.js new file mode 100644 index 0000000..8add5dc --- /dev/null +++ b/date.js @@ -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 +}; \ No newline at end of file diff --git a/dialog.css b/dialog.css new file mode 100644 index 0000000..0dd489f --- /dev/null +++ b/dialog.css @@ -0,0 +1,95 @@ +@property --jsl-dialog-bgcolor { + syntax: ""; + inherits: true; + initial-value: #fff; +} +@property --jsl-dialog-bordercolor { + syntax: ""; + 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; + } + } + } +} \ No newline at end of file diff --git a/dialog.js b/dialog.js new file mode 100644 index 0000000..33fa672 --- /dev/null +++ b/dialog.js @@ -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 +}; diff --git a/dom.js b/dom.js new file mode 100644 index 0000000..99f3b7d --- /dev/null +++ b/dom.js @@ -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 }; diff --git a/index.html b/index.html new file mode 100644 index 0000000..99481b3 --- /dev/null +++ b/index.html @@ -0,0 +1,80 @@ + + + + + + + + + + + + + diff --git a/json.js b/json.js new file mode 100644 index 0000000..a152334 --- /dev/null +++ b/json.js @@ -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, +}; \ No newline at end of file diff --git a/jsonform.css b/jsonform.css new file mode 100644 index 0000000..0e5eab2 --- /dev/null +++ b/jsonform.css @@ -0,0 +1,62 @@ +@property --jsl-form-text-default { + syntax: ""; + inherits: true; + initial-value: #444; +} +@property --jsl-form-text-editable { + syntax: ""; + inherits: true; + initial-value: #000; +} +@property --jsl-form-default-outline { + syntax: ""; + inherits: true; + initial-value: #ccc; +} +@property --jsl-form-editable-outline { + syntax: ""; + inherits: true; + initial-value: #6666b1; +} +@property --jsl-form-focused-outline { + syntax: ""; + 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); + } + } + } + } + } +} \ No newline at end of file diff --git a/jsonform.js b/jsonform.js new file mode 100644 index 0000000..0ff5b3a --- /dev/null +++ b/jsonform.js @@ -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, +} \ No newline at end of file