-
Notifications
You must be signed in to change notification settings - Fork 7
/
chocfactory.js
206 lines (180 loc) · 9.41 KB
/
chocfactory.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
/* Chocolate Factory v0.6.1
NOTE: This version is static and unchanging. New updates are only found at the
canonial location: https://rosuav.github.io/choc/
DOM object builder. (Thanks to DeviCat for the name!)
Recommended for use in a module script:
import choc, {set_content, on, DOM} from "https://rosuav.github.io/choc/factory.js";
const {FORM, LABEL, INPUT} = choc;
Can also be used in HTML:
<script type=module src="https://rosuav.github.io/choc/factory.js"></script>
<script defer src="/path/to/your/script.js"></script>
Once imported, the chocolate factory can be used in a number of ways:
* TAG(attr, contents) // recommended (requires second line of import)
* choc.TAG(attr, contents) // also supported (does not require destructuring)
* choc("TAG", attr, contents) // suitable for dynamic tag selection
* chocify("TAG"); TAG(attr, contents) // deprecated, non-module scripts only
Example:
let el = FORM(LABEL(["Speak thy mind:", INPUT({name: "thought"})]))
Regardless of how it's called, choc will return a newly-created element with
the given tag, attributes, and contents. Both attributes and contents are
optional, but if both are given, must be in that order.
To replace the contents of a DOM element:
set_content(element, contents);
The element can be either an actual DOM element or a selector. The contents
can be a DOM element (eg created by choc() above), or a text string, or an
array of elements and/or strings. Text strings will NOT be interpreted as
HTML, and thus can safely contain untrusted content. Note that this will
update a single element only, and will raise an error if multiple elements
match. (Changed in v0.2: Now raises if duplicates, instead of ignoring them.)
Hooking events can be done by selector. Internally this attaches the event
to the document, so dynamically-created objects can still respond to events.
on("click", ".some-class", e => {console.log("Hello");});
To distinguish between multiple objects that potentially match, e.match
will be set to the object that received the event. (This is distinct from
e.target and e.currentTarget.) NOTE: e.match is wiped after the event
handler returns. For asynchronous use, capture it in a variable first.
Additional options can be set with another argument, eg passing true to have
the event handler attached to the capturing phase instead. Important for some
types of events, irrelevant for others. New in v0.6.
For other manipulations of DOM objects, start by locating one by its selector:
DOM('input[name="thought"]').value = "..."
This is like document.querySelector(), but ensures that there is only one
matching element, thus avoiding the risk of catching the wrong one. (It's also
shorter. Way shorter.)
If you use the <dialog> tag, consider fix_dialogs(). It adds basic support to
browsers which lack it, and can optionally provide automatic behaviour for
close buttons and/or clicking outside the dialog to close it.
fix_dialogs({close_selector: "button.close,input[type=submit]"});
fix_dialogs({click_outside: true});
//New in v0.5: Clicking outside dialogs closes them, but only if there is
//no <form> inside the <dialog>. Guards against accidental closings.
fix_dialogs({click_outside: "formless"});
The MIT License (MIT)
Copyright (c) 2021 Chris Angelico
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
export function DOM(sel) {
const elems = document.querySelectorAll(sel);
if (elems.length > 1) throw new Error("Expected a single element '" + sel + "' but got " + elems.length);
return elems[0]; //Will return undefined if there are no matching elements.
}
//Append one child or an array of children
function append_child(elem, child) {
if (!child || child === "") return;
if (Array.isArray(child)) {
//TODO maybe: prevent infinite nesting (array inside itself)
for (let c of child) append_child(elem, c);
return;
}
if (typeof child === "string" || typeof child === "number") child = document.createTextNode(child);
elem.appendChild(child);
}
export function set_content(elem, children) {
if (typeof elem === "string") elem = DOM(elem);
while (elem.lastChild) elem.removeChild(elem.lastChild);
append_child(elem, children);
return elem;
}
const handlers = {};
export function on(event, selector, handler, options) {
if (handlers[event]) return handlers[event].push([selector, handler]);
handlers[event] = [[selector, handler]];
document.addEventListener(event, e => {
//Reimplement bubbling ourselves
const top = e.currentTarget; //Generic in case we later allow this to attach to other than document
let cur = e.target;
while (cur && cur !== top) {
e.match = cur; //We can't mess with e.currentTarget without synthesizing our own event object. Easier to make a new property.
handlers[event].forEach(([s, h]) => cur.matches(s) && h(e));
cur = cur.parentNode;
}
e.match = null; //Signal that you can't trust the match ref any more
}, options);
return 1;
}
//Apply some patches to <dialog> tags to make them easier to use. Accepts keyword args in a config object:
// fix_dialogs({close_selector: ".dialog_cancel,.dialog_close", click_outside: true});
//For older browsers, this adds showModal() and close() methods
//If cfg.close_selector, will hook events from all links/buttons matching it to close the dialog
//If cfg.click_outside, any click outside a dialog will also close it. (May not work on older browsers.)
export function fix_dialogs(cfg) {
if (!cfg) cfg = {};
//For browsers with only partial support for the <dialog> tag, add the barest minimum.
//On browsers with full support, there are many advantages to using dialog rather than
//plain old div, but this way, other browsers at least have it pop up and down.
let need_button_fix = false;
document.querySelectorAll("dialog").forEach(dlg => {
if (!dlg.showModal) {
dlg.showModal = function() {this.style.display = "block";}
dlg.close = function(ret) {
if (ret) this.returnValue = ret;
this.style.removeProperty("display");
this.dispatchEvent(new CustomEvent("close", {bubbles: true}));
};
need_button_fix = true;
}
});
//Ideally, I'd like to feature-detect whether form[method=dialog] actually
//works, and do this if it doesn't; we assume that the lack of a showModal
//method implies this being also unsupported. New in Choc Factory 0.4.
if (need_button_fix) on("click", 'dialog form[method="dialog"] button', e => {
e.match.closest("dialog").close(e.match.value);
e.preventDefault();
});
if (cfg.click_outside) on("click", "dialog", e => {
//NOTE: Sometimes, clicking on a <select> will give spurious clientX/clientY
//values. Since clicking outside is always going to send the message directly
//to the dialog (not to one of its children), check for that case.
if (e.match !== e.target) return;
if (cfg.click_outside === "formless" && e.match.querySelector("form")) return;
let rect = e.match.getBoundingClientRect();
if (e.clientY < rect.top || e.clientY > rect.top + rect.height
|| e.clientX < rect.left || e.clientX > rect.left + rect.width)
{
e.match.close();
e.preventDefault();
}
});
if (cfg.close_selector) on("click", cfg.close_selector, e => e.match.closest("dialog").close());
}
let choc = function(tag, attributes, children) {
const ret = document.createElement(tag);
//If called as choc(tag, children), assume all attributes are defaults
if (typeof attributes === "string" || attributes instanceof Array || attributes instanceof Element)
return set_content(ret, attributes);
if (attributes) for (let attr in attributes) {
if (attr.startsWith("data-")) //Simplistic - we don't transform "data-foo-bar" into "fooBar" per HTML.
ret.dataset[attr.slice(5)] = attributes[attr];
else if (attr === "form") //Setting the form attribute on an element has to be done differently.
ret.setAttribute("form", attributes[attr]);
else ret[attr] = attributes[attr];
}
if (children) set_content(ret, children);
return ret;
}
choc.__version__ = "0.6.1";
//Interpret choc.DIV(attr, chld) as choc("DIV", attr, chld)
//This is basically what Python would do as choc.__getattr__()
choc = new Proxy(choc, {get: function(obj, prop) {
if (prop in obj) return obj[prop];
return obj[prop] = (a, c) => obj(prop, a, c);
}});
//For modules, make the main entry-point easily available.
export default choc;
//For non-module scripts, allow some globals to be used
window.choc = choc; window.set_content = set_content; window.on = on; window.DOM = DOM; window.fix_dialogs = fix_dialogs;
window.chocify = tags => tags.split(" ").forEach(tag => window[tag] = choc[tag]); //Deprecated, will be removed in 1.0