@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 +21 -0
- package/README.md +137 -0
- package/index.js +4 -0
- package/package.json +36 -0
- package/src/core/Domo.js +290 -0
- package/src/core/DomoSVG.js +63 -0
- package/src/core/Router.js +203 -0
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
|
+

|
|
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
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
|
+
}
|
package/src/core/Domo.js
ADDED
|
@@ -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
|
+
};
|