@zyrab/domo 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Zyrab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,137 @@
1
+ # Domo
2
+
3
+ ![Domo Logo](./assets/logo.png)
4
+
5
+ **Domo** is a tiny fluent helper for creating and working with DOM elements.
6
+ It simplifies native APIs with a chainable, intuitive interface.
7
+
8
+ Originally built for personal use, it's growing into a lightweight UI toolkit with a router, planned components, and scoped styles.
9
+ No dependencies. No build step. Just clean, direct DOM manipulation.
10
+
11
+ ---
12
+
13
+ ## Features
14
+
15
+ - Fluent, chainable DOM API
16
+ - Set ID, text, value, class, style, data, attributes
17
+ - Conditional rendering with `.if()` and `.show()`
18
+ - Event handling: `.on`, `.onClosest`, `.onMatch`
19
+ - Simple DOM ops: `.append`, `.clear`, `.replace`
20
+ - Built-in router:
21
+ - History API, nested/dynamic routes
22
+ - Scroll and metadata handling
23
+ - Route info and listeners
24
+
25
+ ---
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ npm install @zyrab/domo
31
+ ```
32
+
33
+ ---
34
+
35
+ ## Usage
36
+
37
+ ```js
38
+ import Domo from " @zyrab/domo";
39
+
40
+ const btn = Domo("button")
41
+ .id("submit-btn")
42
+ .cls(["btn", "primary"])
43
+ .txt("Submit")
44
+ .on("click", () => alert("Submitted!"))
45
+ .build();
46
+
47
+ document.body.appendChild(btn);
48
+ ```
49
+
50
+ ---
51
+
52
+ ## Router
53
+
54
+ The built-in router enables history-based navigation with a simple nested config structure.
55
+
56
+ ```js
57
+ import { Router } from "@zyrab/domo";
58
+
59
+ Router.routes({
60
+ "/": { component: Home, meta: { title: "Home" } },
61
+ "/about": { component: About, meta: { title: "About" } },
62
+ "/blog": {
63
+ children: {
64
+ "/": { component: Blog, meta: { title: "Blog" } },
65
+ "/:slug": { component: BlogPost, meta: { title: "Post" } },
66
+ },
67
+ },
68
+ "*": { component: Error, meta: { title: "404" } },
69
+ });
70
+
71
+ document.body.appendChild(Router.mount());
72
+ Router.init();
73
+
74
+ Router.goTo("/about");
75
+
76
+ Router.back();
77
+
78
+ Router.listen(({ meta, params }) => {
79
+ console.log("Route changed:", meta.title, params);
80
+ });
81
+
82
+ const { meta, params, segments } = Router.info();
83
+ ```
84
+
85
+ ---
86
+
87
+ ## Planned
88
+
89
+ - DOM-based components
90
+ - Custom scoped style system
91
+ - Prebuilt reusable elements
92
+ - Examples folder with real use cases
93
+
94
+ ---
95
+
96
+ ## API Reference
97
+
98
+ - Domo(tag = "div") — Creates a DOM element
99
+ - .id(string)
100
+ - .val(string)
101
+ - .txt(string)
102
+ - .cls(string | string[])
103
+ - .rmvCls(string | string[])
104
+ - .tgglCls(string, force?)
105
+ - .attr(object)
106
+ - .tgglAttr(name, force?)
107
+ - .data(object)
108
+ - .css(styles)
109
+ - .on(event, callback)
110
+ - .onMatch(event, { selector: callback })
111
+ - .onClosest(event, { selector: callback })
112
+ - .child([children])
113
+ - .clear()
114
+ - .replace(child, newChild)
115
+ - .show(bool, display?)
116
+ - .if(condition)
117
+ - .ref(callback) — Callback access to raw element
118
+ - .build() — Returns the constructed HTMLElement
119
+
120
+ Full reference:
121
+ [Domo](docs/Domo.md)
122
+ [Router](docs/Router.md)
123
+
124
+ ---
125
+
126
+ ## Contributing
127
+
128
+ Suggestions, fixes, or features are welcome.
129
+ This is a small project made for personal use — but if you see something worth improving, feel free to help.
130
+
131
+ → [Read the Contributing Guide](CONTRIBUTING)
132
+
133
+ ---
134
+
135
+ ## License
136
+
137
+ [MIT License](LICENSE)
package/index.js ADDED
@@ -0,0 +1,4 @@
1
+ import Domo from "./src/core/Domo.js";
2
+ import Router from "./src/core/Router.js";
3
+
4
+ export { Domo, Router };
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@zyrab/domo",
3
+ "version": "1.0.0",
4
+ "description": "Minimalist DOM builder and chaining-friendly micro-framework with router support.",
5
+ "main": "index.js",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./index.js"
9
+ },
10
+ "files": [
11
+ "index.js",
12
+ "src/"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/zyrab/domo.git"
17
+ },
18
+ "keywords": [
19
+ "dom",
20
+ "builder",
21
+ "declarative",
22
+ "chaining",
23
+ "vanilla-js",
24
+ "router",
25
+ "components",
26
+ "event-delegation",
27
+ "zyrab"
28
+ ],
29
+ "author": "Zyrab",
30
+ "license": "MIT",
31
+ "bugs": {
32
+ "url": "https://github.com/zyrab/domo/issues"
33
+ },
34
+ "homepage": "https://github.com/zyrab/domo#readme",
35
+ "logo": "https://github.com/zyrab/domo/blob/main/assets/logo.png"
36
+ }
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Domo - Lightweight helper class for creating and manipulating DOM elements fluently.
3
+ */
4
+ class Domo {
5
+ /**
6
+ * @param {string} [el='div'] - The tag name of the element to create.
7
+ */
8
+ constructor(el = "div") {
9
+ this.element = this.el(el);
10
+ }
11
+
12
+ /**
13
+ * Creates a DOM element.
14
+ * @param {string} el - Element tag name.
15
+ * @returns {HTMLElement}
16
+ */
17
+ el(el) {
18
+ return document.createElement(String(el || "div").toLowerCase());
19
+ }
20
+
21
+ /**
22
+ * Provides direct reference to the created element.
23
+ * @param {(el: HTMLElement) => void} callBack
24
+ * @returns {Domo}
25
+ */
26
+ ref(callBack) {
27
+ if (typeof callBack === "function") callBack(this.element);
28
+ return this;
29
+ }
30
+
31
+ /**
32
+ * Sets a property on the element if the value is not undefined.
33
+ * @private
34
+ * @param {string} key
35
+ * @param {*} val
36
+ * @returns {Domo}
37
+ */
38
+ _set(key, val) {
39
+ if (val !== undefined) this.element[key] = val;
40
+ return this;
41
+ }
42
+
43
+ /** @param {string} id */
44
+ id(id) {
45
+ return this._set("id", id);
46
+ }
47
+
48
+ /** @param {string} value */
49
+ val(value) {
50
+ return this._set("value", value);
51
+ }
52
+
53
+ /** @param {string} text */
54
+ txt(text) {
55
+ return this._set("textContent", text);
56
+ }
57
+
58
+ /**
59
+ * Normalizes a class list input.
60
+ * @private
61
+ * @param {string|string[]} input
62
+ * @returns {string[]}
63
+ */
64
+ _parseClassList(input) {
65
+ return Array.isArray(input)
66
+ ? input.filter(Boolean)
67
+ : String(input).split(" ").filter(Boolean);
68
+ }
69
+
70
+ /**
71
+ * Adds classes
72
+ * @param {string|string[]} classes
73
+ */
74
+ cls(classes) {
75
+ if (classes) {
76
+ this.element.classList.add(...this._parseClassList(classes));
77
+ }
78
+ return this;
79
+ }
80
+
81
+ /**
82
+ * Removes classes
83
+ * @param {string|string[]} classes
84
+ */
85
+ rmvCls(classes) {
86
+ if (classes) {
87
+ this.element.classList.remove(...this._parseClassList(classes));
88
+ }
89
+ return this;
90
+ }
91
+
92
+ /**
93
+ * Toggles a class.
94
+ * @param {string} className
95
+ * @param {boolean} [force]
96
+ */
97
+ tgglCls(className, force) {
98
+ this.element.classList.toggle(className, force);
99
+ return this;
100
+ }
101
+
102
+ /**
103
+ * Sets attributes (skips event attributes).
104
+ * @param {Record<string, any>} attributes
105
+ */
106
+ attr(attributes = {}) {
107
+ Object.entries(attributes).forEach(([key, value]) => {
108
+ if (key.startsWith("on")) return;
109
+ if (typeof value === "boolean") {
110
+ if (value) this.element.setAttribute(key, "");
111
+ else this.element.removeAttribute(key);
112
+ } else if (value != null) {
113
+ this.element.setAttribute(key, value);
114
+ }
115
+ });
116
+ return this;
117
+ }
118
+
119
+ /**
120
+ * Toggles an attribute.
121
+ * @param {string} attrName
122
+ * @param {boolean} [force]
123
+ */
124
+ tgglAttr(attrName, force) {
125
+ if (attrName.startsWith("on")) return;
126
+ if (typeof force === "boolean") {
127
+ if (force) {
128
+ this.element.setAttribute(attrName, "");
129
+ } else {
130
+ this.element.removeAttribute(attrName);
131
+ }
132
+ } else {
133
+ if (this.element.hasAttribute(attrName)) {
134
+ this.element.removeAttribute(attrName);
135
+ } else {
136
+ this.element.setAttribute(attrName, "");
137
+ }
138
+ }
139
+ return this;
140
+ }
141
+
142
+ /**
143
+ * Sets data-* attributes.
144
+ * @param {Record<string, string>} data
145
+ */
146
+ data(data = {}) {
147
+ Object.entries(data).forEach(([key, val]) => {
148
+ this.element.dataset[key] = val;
149
+ });
150
+ return this;
151
+ }
152
+
153
+ /**
154
+ * Sets CSS styles.
155
+ * @param {Partial<CSSStyleDeclaration>} styles
156
+ */
157
+ css(styles = {}) {
158
+ Object.assign(this.element.style, styles);
159
+ return this;
160
+ }
161
+
162
+ /**
163
+ * Adds an event listener.
164
+ * @param {string} event
165
+ * @param {EventListenerOrEventListenerObject} callback
166
+ * @param {AddEventListenerOptions} [options]
167
+ */
168
+ on(event, callback, options = {}) {
169
+ this.element.addEventListener(event, callback, options);
170
+ return this;
171
+ }
172
+
173
+ /** @private */
174
+ _handleClosest(e, map) {
175
+ for (const [selector, handler] of Object.entries(map)) {
176
+ const match = e.target.closest(selector);
177
+ if (match) handler(e, match);
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Delegates events to closest matching ancestor.
183
+ * @param {string} event
184
+ * @param {Record<string, (e: Event, target: Element) => void>} selectors
185
+ * @param {AddEventListenerOptions} [options]
186
+ */
187
+ onClosest(event, selectors = {}, options = {}) {
188
+ return this.on(event, (e) => this._handleClosest(e, selectors), options);
189
+ }
190
+
191
+ /** @private */
192
+ _handleMatches(e, map) {
193
+ for (const [selector, handler] of Object.entries(map)) {
194
+ if (e.target.matches(selector)) handler(e, e.target);
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Delegates events using element.matches.
200
+ * @param {string} event
201
+ * @param {Record<string, (e: Event, target: Element) => void>} selectors
202
+ * @param {AddEventListenerOptions} [options]
203
+ */
204
+ onMatch(event, selectors = {}, options = {}) {
205
+ return this.on(event, (e) => this._handleMatches(e, selectors), options);
206
+ }
207
+
208
+ /** @private */
209
+ _handleElementInstance(element) {
210
+ if (element instanceof Domo) return element.build();
211
+ if (element instanceof DocumentFragment) return element;
212
+ if (element instanceof Node) return element;
213
+ if (typeof element === "string" || typeof element === "number") {
214
+ return document.createTextNode(element);
215
+ }
216
+ return document.createTextNode(`⚠ Invalid child: ${String(element)}`);
217
+ }
218
+
219
+ /**
220
+ * Appends children (can be nested arrays, strings, numbers, fragments, elements).
221
+ * @param {any[]} children
222
+ */
223
+ child(children = []) {
224
+ const flattenedChildren = children.flat();
225
+ flattenedChildren.forEach((child) => {
226
+ this.element.appendChild(this._handleElementInstance(child));
227
+ });
228
+ return this;
229
+ }
230
+
231
+ /**
232
+ * Removes all children.
233
+ */
234
+ clear() {
235
+ this.element.replaceChildren();
236
+ return this;
237
+ }
238
+
239
+ /**
240
+ * Replaces a child element or self with a new one.
241
+ * @param {Node} child
242
+ * @param {any} newChild
243
+ */
244
+ replace(child, newChild) {
245
+ const instance = this._handleElementInstance(newChild);
246
+
247
+ if (child === this.element) {
248
+ this.element.replaceWith(instance);
249
+ this.element = instance;
250
+ } else if (this.element.contains(child)) {
251
+ child.replaceWith(instance);
252
+ }
253
+
254
+ return this;
255
+ }
256
+
257
+ /**
258
+ * Shows or hides the element.
259
+ * @param {boolean} [visible=true]
260
+ * @param {string} [displayValue='block']
261
+ */
262
+ show(visible = true, displayValue = "block") {
263
+ this.element.style.display = visible ? displayValue : "none";
264
+ return this;
265
+ }
266
+
267
+ /**
268
+ * Conditionally render element, or return dummy hidden placeholder.
269
+ * @param {boolean} condition
270
+ * @returns {Domo}
271
+ */
272
+ if(condition) {
273
+ if (!condition) {
274
+ return new Domo("if")
275
+ .attr({ hidden: true })
276
+ .data({ if: this.element.tagName.toLowerCase() });
277
+ }
278
+ return this;
279
+ }
280
+
281
+ /**
282
+ * Returns the constructed DOM element.
283
+ * @returns {HTMLElement}
284
+ */
285
+ build() {
286
+ return this.element;
287
+ }
288
+ }
289
+
290
+ export default Domo;
@@ -0,0 +1,63 @@
1
+ class DomoSVG extends Domo {
2
+ constructor(tag = "svg") {
3
+ super(tag); // Call Domo constructor with tag
4
+ if (!this.isSVGTag(tag)) {
5
+ throw new Error(`Invalid SVG tag: ${tag}`);
6
+ }
7
+ }
8
+
9
+ /**
10
+ * Check if the tag is a valid SVG element.
11
+ * @param {string} tag
12
+ * @returns {boolean}
13
+ */
14
+ isSVGTag(tag) {
15
+ const svgTags = [
16
+ "svg",
17
+ "path",
18
+ "circle",
19
+ "rect",
20
+ "line",
21
+ "ellipse",
22
+ "polygon",
23
+ "g",
24
+ "text",
25
+ "use",
26
+ "defs",
27
+ "clipPath",
28
+ "marker",
29
+ "mask",
30
+ "style",
31
+ "linearGradient",
32
+ "radialGradient",
33
+ "stop",
34
+ ];
35
+ return svgTags.includes(tag);
36
+ }
37
+
38
+ /**
39
+ * Override the el method to handle SVG namespace creation.
40
+ * @param {string} tag
41
+ * @returns {HTMLElement}
42
+ */
43
+ el(tag) {
44
+ return this.isSVGTag(tag)
45
+ ? document.createElementNS("http://www.w3.org/2000/svg", tag)
46
+ : super.el(tag); // Fallback to regular DOM creation
47
+ }
48
+
49
+ /**
50
+ * Set attributes specific to SVG elements.
51
+ * @param {Record<string, any>} attributes
52
+ * @returns {DomoSVG}
53
+ */
54
+ attr(attributes = {}) {
55
+ Object.entries(attributes).forEach(([key, value]) => {
56
+ if (key.startsWith("on")) return;
57
+ if (value != null) {
58
+ this.element.setAttributeNS(null, key, value); // Set using SVG-specific namespace
59
+ }
60
+ });
61
+ return this;
62
+ }
63
+ }
@@ -0,0 +1,203 @@
1
+ let _routes = {};
2
+ let _listeners = [];
3
+ const _scrollPositions = {};
4
+ let _previousUrl = "";
5
+
6
+ let _root = document.createElement("main");
7
+ _root.id = "main";
8
+
9
+ function init() {
10
+ ["DOMContentLoaded", "popstate"].forEach((event) =>
11
+ window.addEventListener(event, async () => {
12
+ saveScroll(_previousUrl);
13
+ const url = path();
14
+ load(url);
15
+ _previousUrl = url;
16
+ restoreScroll();
17
+ })
18
+ );
19
+ }
20
+
21
+ async function render({ component, meta }, params) {
22
+ try {
23
+ const content = await component(params);
24
+ _root.replaceChildren();
25
+
26
+ if (content instanceof HTMLElement) {
27
+ _root.appendChild(content);
28
+ } else if (typeof content === "string") {
29
+ const wrapper = document.createElement("div");
30
+ wrapper.textContent = content;
31
+ _root.appendChild(wrapper);
32
+ } else {
33
+ throw new Error("Unsupported component output type");
34
+ }
35
+ if (meta) {
36
+ document.title = meta?.title;
37
+ document
38
+ .querySelector("meta[name='description']")
39
+ .setAttribute("content", meta?.description);
40
+ }
41
+ } catch (error) {
42
+ console.error("Rendering error:", error);
43
+ const fallback = _routes["*"]?.component?.({ error: err.message });
44
+ if (fallback) {
45
+ _root.replaceChildren();
46
+ _root.appendChild(fallback);
47
+ }
48
+ }
49
+ }
50
+ async function goTo(path) {
51
+ saveScroll(_previousUrl);
52
+ await load(path);
53
+ _previousUrl = path;
54
+ restoreScroll();
55
+ }
56
+
57
+ const path = () => window.location.pathname + window.location.hash;
58
+
59
+ function info() {
60
+ const { segments } = parseUrl(path());
61
+ const { routeData, params } = match(segments);
62
+ return { meta: routeData.meta || {}, params, segments };
63
+ }
64
+
65
+ function parseUrl(url) {
66
+ // Remove the hash from the URL
67
+ const pureUrl = url.includes("#") ? url.split("#")[0] : url;
68
+ // Split the URL into segments keepinig '/' for nested routes
69
+ const segments = pureUrl.split(/(?=\/)/g).filter(Boolean);
70
+ return {
71
+ segments,
72
+ pureUrl,
73
+ };
74
+ }
75
+
76
+ function match(segments) {
77
+ if (!segments.length) return { routeData: _routes["/"] || _routes["*"] };
78
+
79
+ let current = _routes;
80
+ let params = {};
81
+
82
+ for (const segment of segments) {
83
+ if (current[segment]) {
84
+ // exact match found go deeper
85
+ current = current[segment].children || current[segment];
86
+ } else {
87
+ // look for dynamic route
88
+ const dynamic = Object.keys(current).find((k) => k.includes(":"));
89
+ if (!dynamic) return { routeData: _routes["*"], params: {} };
90
+
91
+ const parmName = dynamic.split(":")[1];
92
+ params = { ...params, [parmName]: segment.split("/")[1] };
93
+ current = current[dynamic].children || current[dynamic];
94
+ }
95
+ }
96
+ // we send for rendering default child if component doesnt exists
97
+ const final = current.component ? current : current["/"] || _routes["*"];
98
+
99
+ return { params, routeData: final };
100
+ }
101
+
102
+ async function load(url) {
103
+ const { segments, pureUrl } = parseUrl(url);
104
+ const { routeData, params } = match(segments);
105
+
106
+ if (path() !== url) {
107
+ history.pushState(null, null, pureUrl);
108
+ }
109
+
110
+ if (url === _previousUrl) return;
111
+
112
+ await render(routeData, params);
113
+ if (_listeners.length > 0) notify(info());
114
+ }
115
+
116
+ function notify(info) {
117
+ _listeners.forEach((cb) => cb(info));
118
+ }
119
+ function saveScroll(path = window.location.pathname) {
120
+ _scrollPositions[path] = window.scrollY;
121
+ }
122
+
123
+ function restoreScroll() {
124
+ const pos = _scrollPositions[window.location.pathname];
125
+ window.scrollTo(0, pos || 0);
126
+ }
127
+
128
+ export const Router = {
129
+ /**
130
+ * Mount point of the router, where components will be rendered.
131
+ * @returns {HTMLElement} The root DOM element used by the router.
132
+ */
133
+ mount: () => _root,
134
+
135
+ /**
136
+ * Initializes the router by setting up event listeners and loading the initial route.
137
+ */
138
+ init,
139
+
140
+ /**
141
+ * Programmatically navigate to a new route.
142
+ * @param {string} path - The path to navigate to.
143
+ * @returns {Promise<void>}
144
+ */
145
+ goTo,
146
+
147
+ /**
148
+ * Navigate one step back in browser history.
149
+ */
150
+ back: () => history.back(),
151
+
152
+ /**
153
+ * Get the current full path including hash.
154
+ * @returns {string} The current path.
155
+ */
156
+ path,
157
+
158
+ /**
159
+ * Get the previous URL path.
160
+ * @returns {string}
161
+ */
162
+ prev: () => _previousUrl || "/",
163
+
164
+ /**
165
+ * Returns the base (first) segment of the current path.
166
+ * @returns {string}
167
+ */
168
+ base: () => parseUrl(path()).segments[0],
169
+
170
+ /**
171
+ * Returns info about the current route.
172
+ * @returns {{ meta: object, params: object, segments: string[] }}
173
+ */
174
+ info,
175
+
176
+ /**
177
+ * Define your route structure.
178
+ *
179
+ * @param {Object} config - Route config object. Supports nested and dynamic routes.
180
+ * @example
181
+ * Router.routes({
182
+ * '/': { component: Home, meta: { title: "Home", description: "Welcome" } },
183
+ * '/blog': {
184
+ * children: {
185
+ * '/:slug': { component: BlogPost },
186
+ * '/': { component: Blog }
187
+ * }
188
+ * },
189
+ * '*': { component: NotFound }
190
+ * });
191
+ */
192
+ routes: (config) => {
193
+ _routes = config;
194
+ },
195
+
196
+ /**
197
+ * Register a listener for route changes.
198
+ * @param {(info: ReturnType<typeof info>) => void} fn - Listener callback.
199
+ */
200
+ listen: (fn) => {
201
+ if (typeof fn === "function") _listeners.push(fn);
202
+ },
203
+ };