@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/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
+ }));