@ktfth/stickjs 3.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/CHANGELOG.md +169 -0
- package/README.md +449 -0
- package/bin/registry.json +20 -0
- package/bin/stickjs.js +158 -0
- package/llms.txt +244 -0
- package/package.json +38 -0
- package/stick-ui/components/accordion.html +25 -0
- package/stick-ui/components/autocomplete.html +82 -0
- package/stick-ui/components/command-palette.html +28 -0
- package/stick-ui/components/copy-button.html +12 -0
- package/stick-ui/components/data-table.html +191 -0
- package/stick-ui/components/dialog.html +23 -0
- package/stick-ui/components/dropdown.html +16 -0
- package/stick-ui/components/notification.html +11 -0
- package/stick-ui/components/skeleton.html +11 -0
- package/stick-ui/components/stepper.html +102 -0
- package/stick-ui/components/tabs.html +26 -0
- package/stick-ui/components/toast.html +10 -0
- package/stick-ui/components/toggle-group.html +16 -0
- package/stick-ui/components/toggle.html +9 -0
- package/stick-ui/components/tooltip.html +12 -0
- package/stick-ui/plugins/autocomplete.js +422 -0
- package/stick-ui/plugins/command-palette.js +289 -0
- package/stick-ui/plugins/data-table.js +426 -0
- package/stick-ui/plugins/dropdown.js +70 -0
- package/stick-ui/plugins/stepper.js +155 -0
- package/stick-ui/plugins/toast.js +51 -0
- package/stick-ui/plugins/tooltip.js +67 -0
- package/stick-ui/stick-ui.css +825 -0
- package/stick.d.ts +105 -0
- package/stick.js +655 -0
package/stick.js
ADDED
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Stick.js — declarative behavior for HTML elements
|
|
3
|
+
* https://github.com/kaiquekandykoga/Stickjs
|
|
4
|
+
*
|
|
5
|
+
* Syntax:
|
|
6
|
+
* data-stick="event:handler:param"
|
|
7
|
+
* data-stick-target="#selector" apply handler to another element
|
|
8
|
+
* data-stick-2="event:handler:param" stack behaviors (up to data-stick-5)
|
|
9
|
+
* data-stick-target-2="#other" per-slot target for data-stick-2
|
|
10
|
+
* data-stick-once remove listener after first run
|
|
11
|
+
* data-stick-debounce="300" debounce handler by N ms
|
|
12
|
+
* data-stick-throttle="300" throttle handler by N ms
|
|
13
|
+
* data-stick-confirm="Are you sure?" confirmation dialog before handler
|
|
14
|
+
* data-stick-key="Enter" only fire on matching KeyboardEvent.key (comma-separated)
|
|
15
|
+
* data-stick-prevent always call event.preventDefault() before handler
|
|
16
|
+
* data-stick-stop always call event.stopPropagation() before handler
|
|
17
|
+
* data-stick-passive add listener as passive (good for scroll/touch)
|
|
18
|
+
* data-stick-capture add listener in capture phase
|
|
19
|
+
* data-stick-delegate=".item" event delegation — listen on el, fire for matching children
|
|
20
|
+
* data-stick-method="POST" HTTP method for fetch handler
|
|
21
|
+
* data-stick-swap="beforeend" fetch insertion mode: innerHTML|outerHTML|prepend|append|beforeend|afterbegin|…
|
|
22
|
+
* data-stick-loading="Loading…" button label while fetch is in-flight
|
|
23
|
+
* data-stick-headers='{"Authorization":"Bearer token"}' fetch headers (JSON)
|
|
24
|
+
* data-stick-json='{"key":"{{value}}"}' send JSON body instead of FormData
|
|
25
|
+
*
|
|
26
|
+
* Target selectors (data-stick-target):
|
|
27
|
+
* "#id" querySelector (default)
|
|
28
|
+
* "next" el.nextElementSibling
|
|
29
|
+
* "prev" el.previousElementSibling
|
|
30
|
+
* "parent" el.parentElement
|
|
31
|
+
* "closest:sel" el.closest(sel)
|
|
32
|
+
* "self" el itself (explicit)
|
|
33
|
+
* "siblings" all sibling elements (same parent, excluding el)
|
|
34
|
+
* "all:sel" all elements matching CSS sel (querySelectorAll)
|
|
35
|
+
*
|
|
36
|
+
* Param interpolation — use element properties inside param:
|
|
37
|
+
* {{value}} el.value (input, select, textarea)
|
|
38
|
+
* {{text}} el.textContent
|
|
39
|
+
* {{id}} el.id
|
|
40
|
+
* {{name}} el.name
|
|
41
|
+
* {{checked}} el.checked (true/false)
|
|
42
|
+
* {{index}} el's index among parent's children
|
|
43
|
+
* {{length}} el.children.length
|
|
44
|
+
* {{chars}} el.value.length (or textContent.length)
|
|
45
|
+
* {{url:key}} URLSearchParams value from current page URL (e.g. {{url:q}} → ?q=value)
|
|
46
|
+
* {{data-*}} any data attribute, e.g. {{data-id}}
|
|
47
|
+
* {{attr}} any attribute, e.g. {{href}}, {{src}}
|
|
48
|
+
*
|
|
49
|
+
* Example: data-stick="input:fetch:/api/search?q={{value}}"
|
|
50
|
+
* data-stick="click:dispatch:item-selected:{{data-id}}"
|
|
51
|
+
*
|
|
52
|
+
* Synthetic events:
|
|
53
|
+
* ready — fires immediately on bind (before any user interaction)
|
|
54
|
+
* intersect — fires when element enters the viewport (IntersectionObserver)
|
|
55
|
+
* watch — fires immediately, then on every attribute mutation of el (MutationObserver)
|
|
56
|
+
*
|
|
57
|
+
* Handler signature:
|
|
58
|
+
* Stick.add('name', (el, param, event, target) => { ... })
|
|
59
|
+
*
|
|
60
|
+
* API:
|
|
61
|
+
* Stick.version semver string
|
|
62
|
+
* Stick.add(name, fn) register handler (chainable)
|
|
63
|
+
* Stick.remove(name) unregister handler (chainable)
|
|
64
|
+
* Stick.use(plugin) register a plugin: fn(stick) | { install } | { name: fn }
|
|
65
|
+
* Stick.debug(true?) enable verbose console logging (dev mode)
|
|
66
|
+
* Stick.unbind(el) remove all listeners and reset data-stick-bound (chainable)
|
|
67
|
+
* Stick.handlers read-only map of registered handler names → fns
|
|
68
|
+
* Stick.init(root?) scan and bind [data-stick] elements (chainable)
|
|
69
|
+
* Stick.observe(root?) auto-bind new elements via MutationObserver (chainable)
|
|
70
|
+
* Stick.parse(value) parse string → { event, handler, param } | null
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
(function (root, factory) {
|
|
74
|
+
// UMD: works as <script>, CommonJS (require), or ESM (import)
|
|
75
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
76
|
+
module.exports = factory();
|
|
77
|
+
} else {
|
|
78
|
+
root.Stick = factory();
|
|
79
|
+
}
|
|
80
|
+
}(typeof window !== 'undefined' ? window : this, function () {
|
|
81
|
+
'use strict';
|
|
82
|
+
|
|
83
|
+
const VERSION = '3.0.0';
|
|
84
|
+
const handlers = {};
|
|
85
|
+
const listenerMap = new WeakMap(); // el → [{event, fn, options}]
|
|
86
|
+
let _debug = false;
|
|
87
|
+
const _hooks = {};
|
|
88
|
+
|
|
89
|
+
function emitHook(name, ...args) {
|
|
90
|
+
(_hooks[name] || []).forEach(fn => {
|
|
91
|
+
try { fn(...args); } catch (err) { console.error('[Stick] hook "' + name + '" threw:', err); }
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── helpers ────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
function debounce(fn, ms) {
|
|
98
|
+
let t;
|
|
99
|
+
return function (...args) { clearTimeout(t); t = setTimeout(() => fn.apply(this, args), ms); };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function throttle(fn, ms) {
|
|
103
|
+
let last = 0;
|
|
104
|
+
return function (...args) {
|
|
105
|
+
const now = Date.now();
|
|
106
|
+
if (now - last >= ms) { last = now; fn.apply(this, args); }
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function swap(target, html, mode) {
|
|
111
|
+
switch (mode) {
|
|
112
|
+
case 'outerHTML': target.outerHTML = html; break;
|
|
113
|
+
case 'prepend':
|
|
114
|
+
case 'afterbegin': target.insertAdjacentHTML('afterbegin', html); break;
|
|
115
|
+
case 'append':
|
|
116
|
+
case 'beforeend': target.insertAdjacentHTML('beforeend', html); break;
|
|
117
|
+
case 'afterend': target.insertAdjacentHTML('afterend', html); break;
|
|
118
|
+
case 'beforebegin': target.insertAdjacentHTML('beforebegin', html); break;
|
|
119
|
+
default: target.innerHTML = html; break;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Walk a cloned DocumentFragment and interpolate {{tokens}} in text nodes and attributes
|
|
124
|
+
function interpolateClone(fragment, sourceEl) {
|
|
125
|
+
const walk = document.createTreeWalker(fragment, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT);
|
|
126
|
+
let node;
|
|
127
|
+
while ((node = walk.nextNode())) {
|
|
128
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
129
|
+
if (node.nodeValue.includes('{{')) node.nodeValue = interpolate(node.nodeValue, sourceEl);
|
|
130
|
+
} else {
|
|
131
|
+
[...node.attributes].forEach(attr => {
|
|
132
|
+
if (attr.value.includes('{{')) attr.value = interpolate(attr.value, sourceEl);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Resolve {{token}} placeholders using properties of el
|
|
139
|
+
function interpolate(param, el) {
|
|
140
|
+
return param.replace(/\{\{([\w-]+)\}\}/g, (_, key) => {
|
|
141
|
+
if (key === 'value') return el.value ?? '';
|
|
142
|
+
if (key === 'text') return el.textContent.trim();
|
|
143
|
+
if (key === 'id') return el.id ?? '';
|
|
144
|
+
if (key === 'name') return el.name ?? '';
|
|
145
|
+
if (key === 'checked') return String(el.checked ?? false);
|
|
146
|
+
if (key === 'index') return el.parentElement ? String([...el.parentElement.children].indexOf(el)) : '0';
|
|
147
|
+
if (key === 'length') return String(el.children?.length ?? 0);
|
|
148
|
+
if (key === 'chars') return String(el.value != null ? el.value.length : el.textContent.length);
|
|
149
|
+
if (key.startsWith('url:')) return (typeof location !== 'undefined' ? new URLSearchParams(location.search).get(key.slice(4)) : null) ?? '';
|
|
150
|
+
if (key.startsWith('data-')) return el.dataset[toCamel(key.slice(5))] ?? '';
|
|
151
|
+
return el.getAttribute(key) ?? '';
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function toCamel(s) {
|
|
156
|
+
return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Resolve data-stick-target → array of Elements
|
|
160
|
+
// Keywords: "self" | "next" | "prev" | "parent" | "siblings" | "closest:sel" | "all:sel" | CSS selector
|
|
161
|
+
function resolveTargets(sel, el) {
|
|
162
|
+
if (!sel) return [el];
|
|
163
|
+
if (sel === 'self') return [el];
|
|
164
|
+
if (sel === 'siblings') return [...(el.parentElement?.children ?? [])].filter(c => c !== el);
|
|
165
|
+
if (sel === 'next') return el.nextElementSibling ? [el.nextElementSibling] : [];
|
|
166
|
+
if (sel === 'prev') return el.previousElementSibling ? [el.previousElementSibling] : [];
|
|
167
|
+
if (sel === 'parent') return el.parentElement ? [el.parentElement] : [];
|
|
168
|
+
if (sel.startsWith('closest:')) { const f = el.closest(sel.slice(8)); return f ? [f] : []; }
|
|
169
|
+
if (sel.startsWith('all:')) return [...document.querySelectorAll(sel.slice(4))];
|
|
170
|
+
const found = document.querySelector(sel);
|
|
171
|
+
return found ? [found] : [];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function closeGroup(target) {
|
|
175
|
+
var group = target.dataset && target.dataset.stickGroup;
|
|
176
|
+
if (!group) return;
|
|
177
|
+
document.querySelectorAll('[data-stick-group="' + group + '"]').forEach(function(el) {
|
|
178
|
+
if (el !== target) el.hidden = true;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function transitionShow(target) {
|
|
183
|
+
var name = target.dataset && target.dataset.stickTransition;
|
|
184
|
+
if (!name) { target.hidden = false; return; }
|
|
185
|
+
target.hidden = false;
|
|
186
|
+
target.classList.add(name + '-enter-active');
|
|
187
|
+
var done = function() { target.classList.remove(name + '-enter-active'); };
|
|
188
|
+
target.addEventListener('animationend', done, { once: true });
|
|
189
|
+
target.addEventListener('transitionend', done, { once: true });
|
|
190
|
+
// Fallback: if no animation/transition is defined, clean up on next frame
|
|
191
|
+
requestAnimationFrame(function() {
|
|
192
|
+
requestAnimationFrame(function() {
|
|
193
|
+
if (target.classList.contains(name + '-enter-active')) {
|
|
194
|
+
var cs = getComputedStyle(target);
|
|
195
|
+
var dur = (parseFloat(cs.animationDuration) || 0) + (parseFloat(cs.transitionDuration) || 0);
|
|
196
|
+
if (dur === 0) done();
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function transitionHide(target) {
|
|
203
|
+
var name = target.dataset && target.dataset.stickTransition;
|
|
204
|
+
if (!name) { target.hidden = true; return; }
|
|
205
|
+
target.classList.add(name + '-leave-active');
|
|
206
|
+
var cleaned = false;
|
|
207
|
+
var done = function() {
|
|
208
|
+
if (cleaned) return;
|
|
209
|
+
cleaned = true;
|
|
210
|
+
target.classList.remove(name + '-leave-active');
|
|
211
|
+
target.hidden = true;
|
|
212
|
+
};
|
|
213
|
+
target.addEventListener('animationend', done, { once: true });
|
|
214
|
+
target.addEventListener('transitionend', done, { once: true });
|
|
215
|
+
// Fallback: if no animation/transition, hide immediately
|
|
216
|
+
requestAnimationFrame(function() {
|
|
217
|
+
requestAnimationFrame(function() {
|
|
218
|
+
if (!cleaned) {
|
|
219
|
+
var cs = getComputedStyle(target);
|
|
220
|
+
var dur = (parseFloat(cs.animationDuration) || 0) + (parseFloat(cs.transitionDuration) || 0);
|
|
221
|
+
if (dur === 0) done();
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── core ──────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
const Stick = {
|
|
230
|
+
version: VERSION,
|
|
231
|
+
|
|
232
|
+
add(name, fn) {
|
|
233
|
+
handlers[name] = fn;
|
|
234
|
+
return this;
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
remove(name) {
|
|
238
|
+
delete handlers[name];
|
|
239
|
+
return this;
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
// Register multiple handlers at once.
|
|
243
|
+
// Accepts: fn(stick), { install(stick) {} }, or { 'name': fn, … }
|
|
244
|
+
use(plugin) {
|
|
245
|
+
if (typeof plugin === 'function') plugin(this);
|
|
246
|
+
else if (plugin && typeof plugin.install === 'function') plugin.install(this);
|
|
247
|
+
else if (plugin && typeof plugin === 'object') {
|
|
248
|
+
Object.entries(plugin).forEach(([name, fn]) => this.add(name, fn));
|
|
249
|
+
}
|
|
250
|
+
return this;
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
/** Enable/disable verbose console logging. Chainable. */
|
|
254
|
+
debug(on = true) { _debug = !!on; return this; },
|
|
255
|
+
|
|
256
|
+
/** Register a lifecycle hook. Events: 'bind', 'unbind'. Chainable. */
|
|
257
|
+
on(event, fn) {
|
|
258
|
+
if (!_hooks[event]) _hooks[event] = [];
|
|
259
|
+
_hooks[event].push(fn);
|
|
260
|
+
return this;
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
// Remove all event listeners from el and reset its bound state
|
|
264
|
+
unbind(el) {
|
|
265
|
+
const listeners = listenerMap.get(el);
|
|
266
|
+
if (listeners) {
|
|
267
|
+
listeners.forEach(({ event, fn, options }) => el.removeEventListener(event, fn, options));
|
|
268
|
+
listenerMap.delete(el);
|
|
269
|
+
}
|
|
270
|
+
el.removeAttribute('data-stick-bound');
|
|
271
|
+
emitHook('unbind', el);
|
|
272
|
+
return this;
|
|
273
|
+
},
|
|
274
|
+
|
|
275
|
+
// Read-only view of all registered handlers
|
|
276
|
+
get handlers() { return Object.freeze({ ...handlers }); },
|
|
277
|
+
|
|
278
|
+
parse(value) {
|
|
279
|
+
if (!value) return null;
|
|
280
|
+
const i1 = value.indexOf(':');
|
|
281
|
+
if (i1 === -1) return null;
|
|
282
|
+
const i2 = value.indexOf(':', i1 + 1);
|
|
283
|
+
// Allow "event:handler" shorthand — trailing colon is optional when param is empty
|
|
284
|
+
if (i2 === -1) return {
|
|
285
|
+
event: value.slice(0, i1).trim(),
|
|
286
|
+
handler: value.slice(i1 + 1).trim(),
|
|
287
|
+
param: '',
|
|
288
|
+
};
|
|
289
|
+
return {
|
|
290
|
+
event: value.slice(0, i1).trim(),
|
|
291
|
+
handler: value.slice(i1 + 1, i2).trim(),
|
|
292
|
+
param: value.slice(i2 + 1).trim(),
|
|
293
|
+
};
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
fire(el, handler, param, event) {
|
|
297
|
+
if (typeof handlers[handler] !== 'function') {
|
|
298
|
+
console.warn('[Stick] fire: unknown handler "' + handler + '"');
|
|
299
|
+
return this;
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
handlers[handler](el, param || '', event || { type: 'fire' }, el);
|
|
303
|
+
} catch (err) {
|
|
304
|
+
console.error('[Stick] handler "' + handler + '" threw:', err);
|
|
305
|
+
}
|
|
306
|
+
return this;
|
|
307
|
+
},
|
|
308
|
+
|
|
309
|
+
bind(el) {
|
|
310
|
+
if (el.hasAttribute('data-stick-bound')) return;
|
|
311
|
+
el.setAttribute('data-stick-bound', '');
|
|
312
|
+
emitHook('bind', el);
|
|
313
|
+
|
|
314
|
+
const once = 'stickOnce' in el.dataset;
|
|
315
|
+
const passive = 'stickPassive' in el.dataset;
|
|
316
|
+
const capture = 'stickCapture' in el.dataset;
|
|
317
|
+
const alwaysPrevent = 'stickPrevent' in el.dataset;
|
|
318
|
+
const alwaysStop = 'stickStop' in el.dataset;
|
|
319
|
+
const confirmMsg = el.dataset.stickConfirm;
|
|
320
|
+
const keyFilter = el.dataset.stickKey ? el.dataset.stickKey.split(',').map(k => k.trim()) : null;
|
|
321
|
+
const delegateSel = el.dataset.stickDelegate || null;
|
|
322
|
+
const debounceMs = parseInt(el.dataset.stickDebounce, 10) || 0;
|
|
323
|
+
const throttleMs = parseInt(el.dataset.stickThrottle, 10) || 0;
|
|
324
|
+
|
|
325
|
+
// Slot keys and their per-slot target attribute names
|
|
326
|
+
// data-stick → data-stick-target (or data-stick-target-1)
|
|
327
|
+
// data-stick-2 → data-stick-target-2
|
|
328
|
+
// data-stick-3 → data-stick-target-3 …
|
|
329
|
+
// Note: data-stick-2 maps to dataset["stick-2"] (not "stick2")
|
|
330
|
+
// because the HTML spec only camelCases hyphens before letters, not digits.
|
|
331
|
+
const slots = [
|
|
332
|
+
{ key: 'stick', targetKey: 'stickTarget' },
|
|
333
|
+
{ key: 'stick-2', targetKey: 'stickTarget-2' },
|
|
334
|
+
{ key: 'stick-3', targetKey: 'stickTarget-3' },
|
|
335
|
+
{ key: 'stick-4', targetKey: 'stickTarget-4' },
|
|
336
|
+
{ key: 'stick-5', targetKey: 'stickTarget-5' },
|
|
337
|
+
];
|
|
338
|
+
|
|
339
|
+
slots.forEach(({ key, targetKey }) => {
|
|
340
|
+
const value = el.dataset[key];
|
|
341
|
+
if (!value) return;
|
|
342
|
+
|
|
343
|
+
const parsed = this.parse(value);
|
|
344
|
+
if (!parsed) return;
|
|
345
|
+
|
|
346
|
+
const { event, handler, param } = parsed;
|
|
347
|
+
|
|
348
|
+
if (typeof handlers[handler] !== 'function') {
|
|
349
|
+
console.warn(`[Stick] unknown handler: "${handler}"`);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Per-slot target: data-stick-target-N, fall back to shared data-stick-target
|
|
354
|
+
const targetSel = el.dataset[targetKey] || el.dataset.stickTarget;
|
|
355
|
+
const targets = resolveTargets(targetSel, el);
|
|
356
|
+
if (targetSel && targets.length === 0) console.warn(`[Stick] target not found: "${targetSel}"`);
|
|
357
|
+
|
|
358
|
+
if (_debug) console.log(`[Stick:bind] ${event}:${handler}:${param}`, el, '→ targets', targets);
|
|
359
|
+
|
|
360
|
+
if (event === 'ready') {
|
|
361
|
+
const resolved = interpolate(param, el);
|
|
362
|
+
if (_debug) console.log(`[Stick:ready] ${handler}`, el, 'param=', resolved);
|
|
363
|
+
targets.forEach(t => {
|
|
364
|
+
try {
|
|
365
|
+
handlers[handler](el, resolved, { type: 'ready' }, t);
|
|
366
|
+
} catch (err) {
|
|
367
|
+
console.error(`[Stick] handler "${handler}" threw:`, err);
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (event === 'watch') {
|
|
374
|
+
// Fire immediately (like ready), then on every attribute mutation of el
|
|
375
|
+
const fire = () => {
|
|
376
|
+
const resolved = interpolate(param, el);
|
|
377
|
+
if (_debug) console.log(`[Stick:watch] ${handler}`, el, 'param=', resolved);
|
|
378
|
+
targets.forEach(t => {
|
|
379
|
+
try {
|
|
380
|
+
handlers[handler](el, resolved, { type: 'watch' }, t);
|
|
381
|
+
} catch (err) {
|
|
382
|
+
console.error(`[Stick] handler "${handler}" threw:`, err);
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
};
|
|
386
|
+
fire();
|
|
387
|
+
new MutationObserver(fire).observe(el, { attributes: true });
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (event === 'intersect') {
|
|
392
|
+
const io = new IntersectionObserver((entries, obs) => {
|
|
393
|
+
entries.forEach((entry) => {
|
|
394
|
+
if (!entry.isIntersecting) return;
|
|
395
|
+
if (confirmMsg && !window.confirm(confirmMsg)) return;
|
|
396
|
+
const resolved = interpolate(param, el);
|
|
397
|
+
targets.forEach(t => {
|
|
398
|
+
try {
|
|
399
|
+
handlers[handler](el, resolved, { type: 'intersect', entry }, t);
|
|
400
|
+
} catch (err) {
|
|
401
|
+
console.error(`[Stick] handler "${handler}" threw:`, err);
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
if (once) obs.unobserve(el);
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
io.observe(el);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
let fn = (e) => {
|
|
412
|
+
// Event delegation: only fire when event.target matches delegateSel
|
|
413
|
+
let activeEl = el;
|
|
414
|
+
if (delegateSel) {
|
|
415
|
+
const matched = e.target instanceof Element ? e.target.closest(delegateSel) : null;
|
|
416
|
+
if (!matched || !el.contains(matched)) return;
|
|
417
|
+
activeEl = matched;
|
|
418
|
+
}
|
|
419
|
+
if (keyFilter && e.key !== undefined && !keyFilter.includes(e.key)) return;
|
|
420
|
+
if (alwaysPrevent) e.preventDefault();
|
|
421
|
+
if (alwaysStop) e.stopPropagation();
|
|
422
|
+
if (confirmMsg && !window.confirm(confirmMsg)) return;
|
|
423
|
+
const resolved = interpolate(param, activeEl);
|
|
424
|
+
// With delegation, resolve targets fresh from the matched child element
|
|
425
|
+
const liveTargets = delegateSel ? resolveTargets(targetSel, activeEl) : targets;
|
|
426
|
+
if (_debug) console.log(`[Stick:fire] ${event}:${handler}`, activeEl, 'param=', resolved, 'targets=', liveTargets);
|
|
427
|
+
liveTargets.forEach(t => {
|
|
428
|
+
try {
|
|
429
|
+
handlers[handler](activeEl, resolved, e, t);
|
|
430
|
+
} catch (err) {
|
|
431
|
+
console.error(`[Stick] handler "${handler}" threw:`, err);
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
if (debounceMs > 0) fn = debounce(fn, debounceMs);
|
|
437
|
+
if (throttleMs > 0) fn = throttle(fn, throttleMs);
|
|
438
|
+
|
|
439
|
+
const options = { once, passive, capture };
|
|
440
|
+
if (!listenerMap.has(el)) listenerMap.set(el, []);
|
|
441
|
+
if (!once) listenerMap.get(el).push({ event, fn, options });
|
|
442
|
+
el.addEventListener(event, fn, options);
|
|
443
|
+
});
|
|
444
|
+
},
|
|
445
|
+
|
|
446
|
+
init(root = document) {
|
|
447
|
+
root.querySelectorAll('[data-stick]').forEach((el) => this.bind(el));
|
|
448
|
+
return this;
|
|
449
|
+
},
|
|
450
|
+
|
|
451
|
+
observe(root = document.body) {
|
|
452
|
+
new MutationObserver((mutations) => {
|
|
453
|
+
mutations.forEach(({ addedNodes }) => {
|
|
454
|
+
addedNodes.forEach((node) => {
|
|
455
|
+
if (node.nodeType !== 1) return;
|
|
456
|
+
if (node.matches('[data-stick]')) this.bind(node);
|
|
457
|
+
node.querySelectorAll?.('[data-stick]').forEach((el) => this.bind(el));
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
}).observe(root, { childList: true, subtree: true });
|
|
461
|
+
return this;
|
|
462
|
+
},
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
// ── built-in handlers ─────────────────────────────────────────────
|
|
466
|
+
|
|
467
|
+
Stick
|
|
468
|
+
.add('show', (el, p, e, t) => {
|
|
469
|
+
transitionShow(t); closeGroup(t);
|
|
470
|
+
if (el) el.setAttribute('aria-expanded', 'true');
|
|
471
|
+
})
|
|
472
|
+
.add('hide', (el, p, e, t) => {
|
|
473
|
+
transitionHide(t);
|
|
474
|
+
if (el) el.setAttribute('aria-expanded', 'false');
|
|
475
|
+
})
|
|
476
|
+
.add('toggle', (el, p, e, t) => {
|
|
477
|
+
if (t.hidden) { transitionShow(t); closeGroup(t); }
|
|
478
|
+
else transitionHide(t);
|
|
479
|
+
if (el) el.setAttribute('aria-expanded', String(!t.hidden));
|
|
480
|
+
})
|
|
481
|
+
.add('add-class', (el, p, e, t) => { t.classList.add(p); })
|
|
482
|
+
.add('remove-class', (el, p, e, t) => { t.classList.remove(p); })
|
|
483
|
+
.add('toggle-class', (el, p, e, t) => { t.classList.toggle(p); })
|
|
484
|
+
.add('set-text', (el, p, e, t) => { t.textContent = p; })
|
|
485
|
+
.add('set-html', (el, p, e, t) => { t.innerHTML = p; })
|
|
486
|
+
.add('set-value', (el, p, e, t) => { t.value = p; })
|
|
487
|
+
.add('clear', (el, p, e, t) => { t.textContent = ''; })
|
|
488
|
+
.add('set-attr', (el, p, e, t) => { const [a, ...v] = p.split(':'); t.setAttribute(a, v.join(':')); })
|
|
489
|
+
.add('remove-attr', (el, p, e, t) => { t.removeAttribute(p); })
|
|
490
|
+
.add('toggle-attr', (el, p, e, t) => { t.hasAttribute(p) ? t.removeAttribute(p) : t.setAttribute(p, ''); })
|
|
491
|
+
.add('set-style', (el, p, e, t) => { const [prop, ...v] = p.split(':'); t.style[prop] = v.join(':'); })
|
|
492
|
+
.add('focus', (el, p, e, t) => { t.focus(); })
|
|
493
|
+
.add('scroll-to', (el, p, e, t) => { t.scrollIntoView({ behavior: 'smooth' }); })
|
|
494
|
+
.add('copy', (el, p) => { navigator.clipboard?.writeText(p || el.textContent.trim()); })
|
|
495
|
+
.add('navigate', (el, p) => { window.location.href = p; })
|
|
496
|
+
.add('open', (el, p) => { window.open(p, '_blank', 'noopener'); })
|
|
497
|
+
.add('back', () => { window.history.back(); })
|
|
498
|
+
.add('forward', () => { window.history.forward(); })
|
|
499
|
+
.add('history-push', (el, p) => { window.history.pushState({}, '', p); })
|
|
500
|
+
.add('select', (el, p, e, t) => { t.select?.(); })
|
|
501
|
+
.add('submit', (el, p, e, t) => { (t.tagName === 'FORM' ? t : t.closest('form'))?.submit(); })
|
|
502
|
+
.add('reset', (el, p, e, t) => { (t.tagName === 'FORM' ? t : t.closest('form'))?.reset(); })
|
|
503
|
+
.add('prevent', (el, p, e) => { e.preventDefault(); })
|
|
504
|
+
.add('stop', (el, p, e) => { e.stopPropagation(); })
|
|
505
|
+
// DOM mutations
|
|
506
|
+
.add('remove', (el, p, e, t) => { t.remove(); })
|
|
507
|
+
.add('disable', (el, p, e, t) => { t.disabled = true; })
|
|
508
|
+
.add('enable', (el, p, e, t) => { t.disabled = false; })
|
|
509
|
+
// Checkbox
|
|
510
|
+
.add('check', (el, p, e, t) => { t.checked = true; })
|
|
511
|
+
.add('uncheck', (el, p, e, t) => { t.checked = false; })
|
|
512
|
+
.add('toggle-check', (el, p, e, t) => { t.checked = !t.checked; })
|
|
513
|
+
// Data attributes
|
|
514
|
+
.add('set-data', (el, p, e, t) => { const [k, ...v] = p.split(':'); t.dataset[toCamel(k)] = v.join(':'); })
|
|
515
|
+
// ARIA attributes
|
|
516
|
+
.add('set-aria', (el, p, e, t) => {
|
|
517
|
+
const [attr, ...v] = p.split(':');
|
|
518
|
+
const name = attr.startsWith('aria-') ? attr : 'aria-' + attr;
|
|
519
|
+
t.setAttribute(name, v.join(':'));
|
|
520
|
+
})
|
|
521
|
+
.add('toggle-aria', (el, p, e, t) => {
|
|
522
|
+
const name = p.startsWith('aria-') ? p : 'aria-' + p;
|
|
523
|
+
const cur = t.getAttribute(name);
|
|
524
|
+
t.setAttribute(name, cur === 'true' ? 'false' : 'true');
|
|
525
|
+
})
|
|
526
|
+
// Counters — param is optional step (default 1); works on inputs and text nodes
|
|
527
|
+
.add('increment', (el, p, e, t) => {
|
|
528
|
+
const step = parseFloat(p) || 1;
|
|
529
|
+
if (t.matches?.('input,select,textarea')) t.value = String((parseFloat(t.value) || 0) + step);
|
|
530
|
+
else t.textContent = String((parseFloat(t.textContent) || 0) + step);
|
|
531
|
+
})
|
|
532
|
+
.add('decrement', (el, p, e, t) => {
|
|
533
|
+
const step = parseFloat(p) || 1;
|
|
534
|
+
if (t.matches?.('input,select,textarea')) t.value = String((parseFloat(t.value) || 0) - step);
|
|
535
|
+
else t.textContent = String((parseFloat(t.textContent) || 0) - step);
|
|
536
|
+
})
|
|
537
|
+
.add('scroll-top', () => { window.scrollTo({ top: 0, behavior: 'smooth' }); })
|
|
538
|
+
.add('reload', () => { window.location.reload(); })
|
|
539
|
+
.add('print', () => { window.print(); })
|
|
540
|
+
// Native <dialog>
|
|
541
|
+
.add('show-modal', (el, p, e, t) => { closeGroup(t); t.showModal?.(); })
|
|
542
|
+
.add('close-modal', (el, p, e, t) => { t.close?.(p || undefined); })
|
|
543
|
+
// Timing
|
|
544
|
+
.add('wait', (el, p, e, t) => {
|
|
545
|
+
const ms = parseInt(p, 10) || 0;
|
|
546
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
547
|
+
})
|
|
548
|
+
// CSS animation — add class, auto-remove after animationend
|
|
549
|
+
.add('animate', (el, p, e, t) => {
|
|
550
|
+
t.classList.add(p);
|
|
551
|
+
t.addEventListener('animationend', () => t.classList.remove(p), { once: true });
|
|
552
|
+
})
|
|
553
|
+
// Template cloning — duplicate <template> content into target; {{tokens}} resolved from el
|
|
554
|
+
.add('clone-template', (el, p, e, t) => {
|
|
555
|
+
const tpl = document.querySelector(p);
|
|
556
|
+
if (!tpl || tpl.tagName !== 'TEMPLATE') {
|
|
557
|
+
console.warn(`[Stick] clone-template: no <template> at "${p}"`);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
const clone = tpl.content.cloneNode(true);
|
|
561
|
+
interpolateClone(clone, el); // resolve {{tokens}} from trigger element
|
|
562
|
+
t.appendChild(clone);
|
|
563
|
+
Stick.init(t);
|
|
564
|
+
})
|
|
565
|
+
// Sort target's children by textContent or a data/attribute key (param: "data-price" or "" for text)
|
|
566
|
+
.add('sort', (el, p, e, t) => {
|
|
567
|
+
const items = [...t.children];
|
|
568
|
+
items.sort((a, b) => {
|
|
569
|
+
const val = (node) => p
|
|
570
|
+
? (node.dataset[toCamel(p.replace(/^data-/, ''))] ?? node.getAttribute(p) ?? node.textContent)
|
|
571
|
+
: node.textContent.trim();
|
|
572
|
+
return val(a).localeCompare(val(b), undefined, { numeric: true });
|
|
573
|
+
});
|
|
574
|
+
items.forEach(i => t.appendChild(i));
|
|
575
|
+
})
|
|
576
|
+
// Count matching elements and set target text (param: CSS selector, or empty = target.children.length)
|
|
577
|
+
.add('count', (el, p, e, t) => {
|
|
578
|
+
const n = p ? document.querySelectorAll(p).length : (t.children?.length ?? 0);
|
|
579
|
+
t.textContent = String(n);
|
|
580
|
+
})
|
|
581
|
+
// Debug
|
|
582
|
+
.add('log', (el, p) => { console.log('[Stick]', p || el); })
|
|
583
|
+
.add('alert', (el, p) => { window.alert(p); })
|
|
584
|
+
// Dispatch a custom event — lets components talk without shared state
|
|
585
|
+
.add('dispatch', (el, p, e, t) => {
|
|
586
|
+
t.dispatchEvent(new CustomEvent(p, { bubbles: true, detail: { source: el } }));
|
|
587
|
+
})
|
|
588
|
+
.add('emit', (el, p, e, t) => { // alias for dispatch
|
|
589
|
+
t.dispatchEvent(new CustomEvent(p, { bubbles: true, detail: { source: el } }));
|
|
590
|
+
})
|
|
591
|
+
// LocalStorage
|
|
592
|
+
.add('store', (el, p) => {
|
|
593
|
+
const [key, ...val] = p.split(':');
|
|
594
|
+
localStorage.setItem(key, val.join(':') || el.value || el.textContent.trim());
|
|
595
|
+
})
|
|
596
|
+
.add('restore', (el, p, e, t) => {
|
|
597
|
+
const val = localStorage.getItem(p);
|
|
598
|
+
if (val !== null) t.value !== undefined ? (t.value = val) : (t.textContent = val);
|
|
599
|
+
})
|
|
600
|
+
// Network
|
|
601
|
+
.add('fetch', async (el, param, e, target) => {
|
|
602
|
+
e?.preventDefault?.();
|
|
603
|
+
const method = (el.dataset.stickMethod || 'GET').toUpperCase();
|
|
604
|
+
const swapMode = el.dataset.stickSwap || 'innerHTML';
|
|
605
|
+
const loading = el.dataset.stickLoading || '';
|
|
606
|
+
const errorSel = el.dataset.stickError;
|
|
607
|
+
const errorEl = errorSel ? document.querySelector(errorSel) : null;
|
|
608
|
+
const prev = el.textContent;
|
|
609
|
+
|
|
610
|
+
let headers = {};
|
|
611
|
+
try { headers = JSON.parse(el.dataset.stickHeaders || '{}'); } catch (_) {}
|
|
612
|
+
|
|
613
|
+
if (loading) el.textContent = loading;
|
|
614
|
+
el.setAttribute('aria-busy', 'true');
|
|
615
|
+
if (errorEl) errorEl.hidden = true;
|
|
616
|
+
|
|
617
|
+
try {
|
|
618
|
+
let body;
|
|
619
|
+
if (method !== 'GET') {
|
|
620
|
+
const jsonTpl = el.dataset.stickJson;
|
|
621
|
+
if (jsonTpl) {
|
|
622
|
+
body = interpolate(jsonTpl, el);
|
|
623
|
+
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
|
|
624
|
+
} else {
|
|
625
|
+
body = new FormData(el.closest('form') || el);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
const res = await fetch(param, { method, body, headers });
|
|
629
|
+
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
|
630
|
+
const text = await res.text();
|
|
631
|
+
swap(target, text, swapMode);
|
|
632
|
+
Stick.init(target);
|
|
633
|
+
} catch (err) {
|
|
634
|
+
console.error('[Stick:fetch]', err);
|
|
635
|
+
if (errorEl) { errorEl.textContent = err.message; errorEl.hidden = false; }
|
|
636
|
+
else target.textContent = `Error: ${err.message}`;
|
|
637
|
+
} finally {
|
|
638
|
+
if (loading) el.textContent = prev;
|
|
639
|
+
el.removeAttribute('aria-busy');
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
// ── auto-init ─────────────────────────────────────────────────────
|
|
644
|
+
|
|
645
|
+
if (typeof document !== 'undefined') {
|
|
646
|
+
if (document.readyState === 'loading') {
|
|
647
|
+
document.addEventListener('DOMContentLoaded', () => { Stick.init(); Stick.observe(); });
|
|
648
|
+
} else {
|
|
649
|
+
Stick.init();
|
|
650
|
+
Stick.observe();
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
return Stick;
|
|
655
|
+
}));
|