commit 6ba8860de91e18f01f7f9e4c0499ce174be1f0ad Author: Christopher Vagnetoft Date: Sat Sep 20 22:59:18 2025 +0200 Initial commit 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