@lordfokas/yrframe 0.1.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 +2 -0
- package/dist/Component.js +74 -0
- package/dist/ComponentEvents.js +165 -0
- package/dist/ComponentFactory.js +75 -0
- package/dist/Facade.js +51 -0
- package/dist/utils.js +3 -0
- package/dist/yrframe.js +3 -0
- package/package.json +25 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 LordFokas
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { ComponentEvents } from "./ComponentEvents.js";
|
|
2
|
+
import { ComponentFactory } from "./ComponentFactory.js";
|
|
3
|
+
const evt = Symbol('evt');
|
|
4
|
+
export class Component extends HTMLElement {
|
|
5
|
+
/** Attribute Event Qualifier chars. Attributes starting with this are special. */
|
|
6
|
+
static AEQ = 'yr:';
|
|
7
|
+
[evt];
|
|
8
|
+
events() {
|
|
9
|
+
return this[evt];
|
|
10
|
+
}
|
|
11
|
+
constructor(props, defaults) {
|
|
12
|
+
super();
|
|
13
|
+
this[evt] = new ComponentEvents(this);
|
|
14
|
+
const all = {};
|
|
15
|
+
// Assign all defaults to object and overwrite with existing values in provided props
|
|
16
|
+
if (defaults)
|
|
17
|
+
Object.assign(all, defaults);
|
|
18
|
+
if (props)
|
|
19
|
+
Object.assign(all, props);
|
|
20
|
+
const attrs = {};
|
|
21
|
+
for (const [k, v] of Object.entries(all)) {
|
|
22
|
+
if (!k.startsWith(Component.AEQ)) {
|
|
23
|
+
attrs[k] = v;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
ComponentFactory.setAttributesAndEvents(this, attrs);
|
|
27
|
+
}
|
|
28
|
+
/** Remove all children nodes. */
|
|
29
|
+
clearChildren() {
|
|
30
|
+
this.innerHTML = "";
|
|
31
|
+
}
|
|
32
|
+
isDisabled() {
|
|
33
|
+
return this.hasAttribute("disabled");
|
|
34
|
+
}
|
|
35
|
+
/** HTML5 Custom Element lifecycle callback (remove from page) */
|
|
36
|
+
disconnectedCallback() {
|
|
37
|
+
this[evt].disconnect();
|
|
38
|
+
}
|
|
39
|
+
/** HTML5 Custom Element lifecycle callback (add to page) */
|
|
40
|
+
connectedCallback() {
|
|
41
|
+
this[evt].connect();
|
|
42
|
+
this.redraw();
|
|
43
|
+
}
|
|
44
|
+
/** Draw this component's internals. Should return only children nodes. */
|
|
45
|
+
render() { return null; }
|
|
46
|
+
/** Redraw this component. Works by deleting all children, calling render() and appending the results. */
|
|
47
|
+
redraw() {
|
|
48
|
+
const child = this.render();
|
|
49
|
+
this.clearChildren();
|
|
50
|
+
if (child) {
|
|
51
|
+
if (Array.isArray(child)) {
|
|
52
|
+
ComponentFactory.appendChildren(this, child);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
ComponentFactory.appendChildren(this, [child]);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
this.inject();
|
|
59
|
+
}
|
|
60
|
+
/** Called after redraw() to do special manipulation of children nodes. */
|
|
61
|
+
inject() { }
|
|
62
|
+
/** Get this component's real width in pixels. */
|
|
63
|
+
width() {
|
|
64
|
+
return parseFloat(getComputedStyle(this, null).width.replace("px", ""));
|
|
65
|
+
}
|
|
66
|
+
/** Get this component's real height in pixels. */
|
|
67
|
+
height() {
|
|
68
|
+
return parseFloat(getComputedStyle(this, null).height.replace("px", ""));
|
|
69
|
+
}
|
|
70
|
+
/** Shortcut to register this component with a given tag name */
|
|
71
|
+
static register(tag) {
|
|
72
|
+
window.customElements.define(tag, this);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { EventBus, EventListener } from '@lordfokas/event-bus';
|
|
2
|
+
class PersistentListener extends EventListener {
|
|
3
|
+
type;
|
|
4
|
+
constructor(type, callback, nice) {
|
|
5
|
+
super(callback, nice);
|
|
6
|
+
this.type = type;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Event manager for component special functions.
|
|
11
|
+
* A developer isn't meant to directly interface with this class,
|
|
12
|
+
* except by calling Component.events()
|
|
13
|
+
*/
|
|
14
|
+
export class ComponentEvents {
|
|
15
|
+
listeners = [];
|
|
16
|
+
sources = {};
|
|
17
|
+
triggers = {};
|
|
18
|
+
component;
|
|
19
|
+
owner;
|
|
20
|
+
constructor(component) {
|
|
21
|
+
this.component = component;
|
|
22
|
+
this.owner = component.constructor.name;
|
|
23
|
+
}
|
|
24
|
+
/** Called by the component when entering DOM */
|
|
25
|
+
connect() {
|
|
26
|
+
this.listeners.forEach(l => EventBus.GLOBAL.subscribe(l.type, l));
|
|
27
|
+
}
|
|
28
|
+
/** Called by the component when leaving DOM */
|
|
29
|
+
disconnect() {
|
|
30
|
+
this.listeners.forEach(l => EventBus.GLOBAL.unsubscribe(l));
|
|
31
|
+
}
|
|
32
|
+
/** Set up and attach a listener, given a configuration */
|
|
33
|
+
createListener(target, name, handler) {
|
|
34
|
+
const [type, path, nice] = target;
|
|
35
|
+
this.listeners.push(new PersistentListener(type, event => {
|
|
36
|
+
handler.call(this.component, event.traverse(...path), event);
|
|
37
|
+
}, nice).named(name, this.owner));
|
|
38
|
+
}
|
|
39
|
+
/** Enforce mandatory targets and skip missing optional ones. */
|
|
40
|
+
skip(target, name, kind, optional) {
|
|
41
|
+
if (!target) {
|
|
42
|
+
if (optional)
|
|
43
|
+
return true;
|
|
44
|
+
else
|
|
45
|
+
throw new Error(`Missing config for mandatory ${kind} '${name}' at '${this.owner}'`);
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
/** Attach a generic listener for an event. */
|
|
50
|
+
attachListener(target, handler, name, kind, optional) {
|
|
51
|
+
if (this.skip(target, name, kind, optional))
|
|
52
|
+
return;
|
|
53
|
+
this.createListener(target, name, handler);
|
|
54
|
+
}
|
|
55
|
+
attachGeneric(target, handler, name, optional) {
|
|
56
|
+
this.attachListener(target, handler, "generic listener", optional);
|
|
57
|
+
}
|
|
58
|
+
attachConsumer(target, handler, name, optional) {
|
|
59
|
+
this.attachListener(target, handler, "consumer", optional);
|
|
60
|
+
}
|
|
61
|
+
/** Attach a listener that enriches the target event with data from this component. */
|
|
62
|
+
attachSupplier(target, source, name, optional) {
|
|
63
|
+
if (this.skip(target, name, "supplier", optional))
|
|
64
|
+
return;
|
|
65
|
+
const [type, path, nice] = target;
|
|
66
|
+
this.listeners.push(new PersistentListener(type, event => {
|
|
67
|
+
const path = target[1];
|
|
68
|
+
const data = source.call(this);
|
|
69
|
+
if (path.length == 0)
|
|
70
|
+
event.with(data);
|
|
71
|
+
else if (path.length == 1)
|
|
72
|
+
event.with({ [path[0]]: data });
|
|
73
|
+
else if (path.length > 1)
|
|
74
|
+
throw new Error("Unsupported path length > 1 for suppliers");
|
|
75
|
+
}, nice).named(name, this.owner));
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Attach a listener that removes data from an event if a predicate passes.
|
|
79
|
+
* If the target configuration has no path, the whole event is stopped instead.
|
|
80
|
+
*/
|
|
81
|
+
attachRemover(target, predicate, name, optional) {
|
|
82
|
+
if (this.skip(target, name, "remover", optional))
|
|
83
|
+
return;
|
|
84
|
+
const [type, path, nice] = target;
|
|
85
|
+
this.listeners.push(new PersistentListener(type, event => {
|
|
86
|
+
const path = target[1];
|
|
87
|
+
if (!predicate.call(this))
|
|
88
|
+
return;
|
|
89
|
+
if (path.length == 0)
|
|
90
|
+
event.stop(`Halted by '${name}' at '${this.owner}'`);
|
|
91
|
+
else if (path.length == 1)
|
|
92
|
+
delete event[path[0]];
|
|
93
|
+
else if (path.length > 1)
|
|
94
|
+
throw new Error("Unsupported path length > 1 for removers");
|
|
95
|
+
}, nice).named(name, this.owner));
|
|
96
|
+
}
|
|
97
|
+
/** Attach a listener that disables a component based on a boolean event field. */
|
|
98
|
+
attachDisabler(target, source, name, optional) {
|
|
99
|
+
if (this.skip(target, name, "disabler", optional))
|
|
100
|
+
return;
|
|
101
|
+
this.createListener(target, name, (value, _) => {
|
|
102
|
+
if (typeof value !== "boolean")
|
|
103
|
+
return;
|
|
104
|
+
const element = source.call(this);
|
|
105
|
+
if (value) {
|
|
106
|
+
element.setAttribute("disabled", "");
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
element.removeAttribute("disabled");
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/** Create a function that will fire an event and process data from it. */
|
|
114
|
+
attachSource(target, name, optional) {
|
|
115
|
+
if (this.skip(target, name, "source", optional))
|
|
116
|
+
return;
|
|
117
|
+
const [type, path] = target;
|
|
118
|
+
this.sources[name] = (callback) => {
|
|
119
|
+
new type().publish().then(event => {
|
|
120
|
+
callback.call(this.component, event.traverse(...path), event);
|
|
121
|
+
});
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/** Create a fake source function that supplies a static or local value. */
|
|
125
|
+
attachStatic(value, source, name, optional) {
|
|
126
|
+
if (value === undefined) {
|
|
127
|
+
if (optional)
|
|
128
|
+
return;
|
|
129
|
+
else
|
|
130
|
+
throw new Error(`Missing config for mandatory static '${name}' at '${this.owner}'`);
|
|
131
|
+
}
|
|
132
|
+
this.sources[name] = (callback) => {
|
|
133
|
+
callback.call(this.component, source.call(this.component), undefined);
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/** Fire a source function and process the returned data. */
|
|
137
|
+
seek(name, callback) {
|
|
138
|
+
if (!this.sources[name])
|
|
139
|
+
callback.call(this.component, undefined, undefined);
|
|
140
|
+
else
|
|
141
|
+
this.sources[name].call(callback);
|
|
142
|
+
}
|
|
143
|
+
/** Create a function that will fire an event to export data. */
|
|
144
|
+
attachTrigger(target, name, optional) {
|
|
145
|
+
if (this.skip(target, name, "trigger", optional))
|
|
146
|
+
return;
|
|
147
|
+
const [type, path] = target;
|
|
148
|
+
this.triggers[name] = (data, parent) => {
|
|
149
|
+
const event = new type();
|
|
150
|
+
if (parent)
|
|
151
|
+
event.parent(parent);
|
|
152
|
+
if (path.length == 0)
|
|
153
|
+
event.with(data);
|
|
154
|
+
if (path.length == 1)
|
|
155
|
+
event.with({ [path[0]]: data });
|
|
156
|
+
if (path.length > 1)
|
|
157
|
+
throw new Error("Unsupported path length > 1 for triggers");
|
|
158
|
+
event.publish();
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/** Fire a trigger function with the given data. */
|
|
162
|
+
fire(name, data = {}, parent) {
|
|
163
|
+
this.triggers[name].call(this.component, data, parent);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { Facade } from "./Facade.js";
|
|
2
|
+
/**
|
|
3
|
+
* JSX / TSX component factory.
|
|
4
|
+
* Should not be invoked manually, is used by TSC when building the project.
|
|
5
|
+
*/
|
|
6
|
+
export class ComponentFactory {
|
|
7
|
+
static make(tag, props, ...children) {
|
|
8
|
+
if (tag === Array)
|
|
9
|
+
return children; // Allow returning multiple elements from render()
|
|
10
|
+
// Initialize Element
|
|
11
|
+
let element;
|
|
12
|
+
if (typeof tag === 'function') { // Construct class based components
|
|
13
|
+
if (tag.prototype instanceof Facade) {
|
|
14
|
+
element = new tag(props ?? {}).content();
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
element = new tag(props ?? {});
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
else { // Construct vanilla HTML tag
|
|
21
|
+
element = document.createElement(tag);
|
|
22
|
+
this.setAttributesAndEvents(element, props);
|
|
23
|
+
}
|
|
24
|
+
// Append children if any
|
|
25
|
+
if (children && children.length > 0) {
|
|
26
|
+
this.appendChildren(element, children);
|
|
27
|
+
}
|
|
28
|
+
return element;
|
|
29
|
+
}
|
|
30
|
+
/** Apply vanilla HTML attributes and event callback functions. There is no component level logic here. */
|
|
31
|
+
static setAttributesAndEvents(element, props) {
|
|
32
|
+
if (props)
|
|
33
|
+
for (const key in props) {
|
|
34
|
+
const value = props[key];
|
|
35
|
+
if (value === undefined)
|
|
36
|
+
continue; // Skip empty prop
|
|
37
|
+
if (typeof value === "function") { // Set vanilla HTML event callback function
|
|
38
|
+
element[key] = value;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
element.setAttribute(key, value);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Logic for appending children to a parent element according to the possible returns of render() */
|
|
46
|
+
static appendChildren(element, children) {
|
|
47
|
+
for (const child of children) {
|
|
48
|
+
// Allow returning null from render() when there is nothing to do
|
|
49
|
+
if (child === null)
|
|
50
|
+
continue;
|
|
51
|
+
// Disallow returning undefined to prevent mistakes being ignored. No-ops must explicitely return null.
|
|
52
|
+
if (child === undefined)
|
|
53
|
+
throw new Error("An element's child cannot be undefined");
|
|
54
|
+
if (Array.isArray(child)) { // Allow returning multiple elements from render()
|
|
55
|
+
this.appendChildren(element, child);
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
element.append(child);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* ## Here be Shenanigans
|
|
65
|
+
* In the event your TSC refuses to find ComponentFactory.make
|
|
66
|
+
* and insists on using React.createElement, fear not:
|
|
67
|
+
*
|
|
68
|
+
* Just `import { FakeReact as React }` and call it a day.
|
|
69
|
+
*
|
|
70
|
+
* At least until you can be arsed to figure it out...
|
|
71
|
+
* `¯\_(ツ)_/¯`
|
|
72
|
+
*/
|
|
73
|
+
export class FakeReact {
|
|
74
|
+
static createElement = ComponentFactory.make;
|
|
75
|
+
}
|
package/dist/Facade.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { ComponentEvents } from "./ComponentEvents.js";
|
|
2
|
+
import { ComponentFactory } from "./ComponentFactory.js";
|
|
3
|
+
const evt = Symbol('evt');
|
|
4
|
+
/**
|
|
5
|
+
* Fake component that constructs and returns another component instead.
|
|
6
|
+
* Used for aliasing and adapting components from other libraries.
|
|
7
|
+
*/
|
|
8
|
+
export class Facade {
|
|
9
|
+
static AEQ = 'yr:'; // TODO: centralize these
|
|
10
|
+
node;
|
|
11
|
+
isCustom;
|
|
12
|
+
events() {
|
|
13
|
+
return this.node[evt];
|
|
14
|
+
}
|
|
15
|
+
constructor(tag, props, defaults) {
|
|
16
|
+
const node = this.node = document.createElement(tag);
|
|
17
|
+
const events = node[evt] = new ComponentEvents(node);
|
|
18
|
+
this.isCustom = tag.includes('-');
|
|
19
|
+
// Attach to existing lifecycle callbacks.
|
|
20
|
+
if (this.isCustom) {
|
|
21
|
+
const { connectedCallback, disconnectedCallback } = node;
|
|
22
|
+
node.connectedCallback = () => {
|
|
23
|
+
events.connect();
|
|
24
|
+
return connectedCallback?.call(node);
|
|
25
|
+
};
|
|
26
|
+
node.disconnectedCallback = () => {
|
|
27
|
+
events.disconnect();
|
|
28
|
+
return disconnectedCallback?.call(node);
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
else { // TODO: rethink this, there's clearly use cases for it.
|
|
32
|
+
throw new Error(`Facade for native element '${tag}' cannot hook to lifecycle calls.`);
|
|
33
|
+
}
|
|
34
|
+
const all = {};
|
|
35
|
+
// Assign all defaults to object and overwrite with existing values in provided props
|
|
36
|
+
if (defaults)
|
|
37
|
+
Object.assign(all, defaults);
|
|
38
|
+
if (props)
|
|
39
|
+
Object.assign(all, props);
|
|
40
|
+
const attrs = {};
|
|
41
|
+
for (const [k, v] of Object.entries(all)) {
|
|
42
|
+
if (!k.startsWith(Facade.AEQ)) {
|
|
43
|
+
attrs[k] = v;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
ComponentFactory.setAttributesAndEvents(node, attrs);
|
|
47
|
+
}
|
|
48
|
+
content() {
|
|
49
|
+
return this.node;
|
|
50
|
+
}
|
|
51
|
+
}
|
package/dist/utils.js
ADDED
package/dist/yrframe.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lordfokas/yrframe",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A Typescript class-based WebComponents library using Material Design.",
|
|
5
|
+
"main": "dist/yrframe.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"prebuild": "rm -rf ./dist",
|
|
8
|
+
"build": "tsc -p ./tsconfig.json",
|
|
9
|
+
"prepublishOnly": "npm run build"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"material",
|
|
13
|
+
"design",
|
|
14
|
+
"components",
|
|
15
|
+
"typescript"
|
|
16
|
+
],
|
|
17
|
+
"author": "LordFokas",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"typescript": "^5.3.3"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@lordfokas/event-bus": "^1.0.1"
|
|
24
|
+
}
|
|
25
|
+
}
|