@brandup/ui 1.0.44 → 2.0.1

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/dist/mjs/index.js CHANGED
@@ -1,4 +1,11 @@
1
1
  let ListenCounter = 1;
2
+ /**
3
+ * Minimal event emitter with support for one-off listeners and cross-emitter listening.
4
+ *
5
+ * The optional `TEvents` type parameter is an event map (`{ eventName: (args) => void }`)
6
+ * that gives subclasses strongly-typed event names, callback signatures and `trigger` arguments.
7
+ * When omitted it defaults to a loose map, so untyped usage keeps working.
8
+ */
2
9
  class EventEmitter {
3
10
  _events;
4
11
  _listenId;
@@ -45,49 +52,98 @@ class EventEmitter {
45
52
  }
46
53
  return this;
47
54
  }
55
+ /**
56
+ * Listen to an event on another emitter, tracking the subscription so it can be released via `stopListening`.
57
+ * @param source Emitter to subscribe to.
58
+ * @param eventName Event name to listen for.
59
+ * @param callback Handler invoked when the event is triggered.
60
+ */
48
61
  listenTo(source, eventName, callback) {
49
- this._addListeningTo(source, eventName);
62
+ this._addListeningTo(source, eventName, callback, callback);
50
63
  source.on(eventName, callback, this);
51
64
  return this;
52
65
  }
66
+ /**
67
+ * Listen once to an event on another emitter; the subscription is tracked and auto-removed after it fires.
68
+ * @param source Emitter to subscribe to.
69
+ * @param eventName Event name to listen for.
70
+ * @param callback Handler invoked once when the event is triggered.
71
+ */
53
72
  listenToOnce(source, eventName, callback) {
54
- this._addListeningTo(source, eventName);
55
- source.once(eventName, callback, this);
73
+ // Use our own one-shot wrapper instead of source.once so that, when it fires,
74
+ // we can drop this exact subscription from BOTH sides — the source and our
75
+ // own _listeningTo registry — keeping the two in sync (no dangling tracking).
76
+ const wrapper = (...args) => {
77
+ source.off(eventName, wrapper, this);
78
+ this._removeListeningSubscription(source, eventName, wrapper);
79
+ callback.apply(this, args);
80
+ };
81
+ this._addListeningTo(source, eventName, wrapper, callback);
82
+ source.on(eventName, wrapper, this);
56
83
  return this;
57
84
  }
85
+ /**
86
+ * Release subscriptions previously established with `listenTo`/`listenToOnce`.
87
+ * Omitted arguments broaden the match (e.g. no `source` releases all tracked emitters).
88
+ * @param source Limit to a specific emitter, or omit for all.
89
+ * @param eventName Limit to a specific event, or omit for all.
90
+ * @param callback Limit to a specific handler, or omit for all.
91
+ */
58
92
  stopListening(source, eventName, callback) {
59
93
  if (!this._listeningTo)
60
94
  return this;
61
- let sourceListening;
62
- if (source) {
63
- if (!source._listenId)
64
- throw new Error("Emmiter is not set id.");
65
- sourceListening = this._listeningTo[source._listenId];
66
- }
67
- const sources = sourceListening ? [sourceListening] : Object.values(this._listeningTo);
68
- sources.forEach(source => {
69
- const removeEventNames = eventName ? [eventName] : source.events;
70
- removeEventNames.forEach(eventName => {
71
- source.emmiter.off(eventName, callback, this);
72
- const index = source.events.indexOf(eventName);
95
+ // A source we never listened to has no tracked subscriptions — nothing to do.
96
+ const listenings = source
97
+ ? (source._listenId && this._listeningTo[source._listenId] ? [this._listeningTo[source._listenId]] : [])
98
+ : Object.values(this._listeningTo);
99
+ const targetEvent = eventName ? eventName.toLowerCase() : undefined;
100
+ listenings.forEach(listening => {
101
+ // snapshot: the loop splices listening.subscriptions
102
+ listening.subscriptions.slice().forEach(sub => {
103
+ if (targetEvent && sub.eventName !== targetEvent)
104
+ return;
105
+ // match the user-supplied callback against both the registered
106
+ // callback and the original (so a listenToOnce can be stopped by
107
+ // the callback the caller passed, not the internal wrapper)
108
+ if (callback && sub.callback !== callback && sub.origin !== callback)
109
+ return;
110
+ listening.emitter.off(sub.eventName, sub.callback, this);
111
+ const index = listening.subscriptions.indexOf(sub);
73
112
  if (index >= 0)
74
- source.events.splice(index, 1);
113
+ listening.subscriptions.splice(index, 1);
75
114
  });
76
- if (!source.events.length && source.emmiter._listenId && this._listeningTo)
77
- delete this._listeningTo[source.emmiter._listenId];
115
+ if (!listening.subscriptions.length && listening.emitter._listenId)
116
+ delete this._listeningTo[listening.emitter._listenId];
78
117
  });
79
118
  if (!this._listeningTo || Object.keys(this._listeningTo).length === 0)
80
119
  delete this._listeningTo;
81
120
  return this;
82
121
  }
83
- _addListeningTo(source, eventName) {
122
+ _addListeningTo(source, eventName, callback, origin) {
84
123
  const listeningTo = this._listeningTo || (this._listeningTo = {});
85
124
  const listenId = source._listenId || (source._listenId = `l${ListenCounter++}`);
86
- const listenTo = listeningTo[listenId] || (listeningTo[listenId] = { emmiter: source, events: [] });
125
+ const listenTo = listeningTo[listenId] || (listeningTo[listenId] = { emitter: source, subscriptions: [] });
126
+ // one entry per subscription (not per event name) so each can be removed
127
+ // independently — e.g. a listenToOnce dropping itself without disturbing
128
+ // a co-registered persistent listenTo on the same source+event.
129
+ listenTo.subscriptions.push({ eventName: eventName.toLowerCase(), callback, origin });
130
+ }
131
+ /** Remove a single tracked subscription (matched by event + registered callback). @internal */
132
+ _removeListeningSubscription(source, eventName, callback) {
133
+ const listenId = source._listenId;
134
+ if (!this._listeningTo || !listenId)
135
+ return;
136
+ const listening = this._listeningTo[listenId];
137
+ if (!listening)
138
+ return;
87
139
  eventName = eventName.toLowerCase();
88
- if (listenTo.events.indexOf(eventName) !== -1)
89
- throw new Error(`Event ${eventName} already listening.`);
90
- listenTo.events.push(eventName);
140
+ const index = listening.subscriptions.findIndex(s => s.eventName === eventName && s.callback === callback);
141
+ if (index >= 0)
142
+ listening.subscriptions.splice(index, 1);
143
+ if (!listening.subscriptions.length)
144
+ delete this._listeningTo[listenId];
145
+ if (Object.keys(this._listeningTo).length === 0)
146
+ delete this._listeningTo;
91
147
  }
92
148
  stopAllListeners() {
93
149
  if (!this._events)
@@ -115,8 +171,10 @@ class EventEmitter {
115
171
  _triggerEvent(events, ...args) {
116
172
  if (!events || !events.length)
117
173
  return;
118
- for (let i = 0; i < events.length; i++) {
119
- const event = events[i];
174
+ // snapshot so callbacks added/removed during dispatch don't affect this trigger
175
+ const snapshot = events.slice();
176
+ for (let i = 0; i < snapshot.length; i++) {
177
+ const event = snapshot[i];
120
178
  event.callback.apply(event.ctx, args);
121
179
  }
122
180
  }
@@ -133,12 +191,463 @@ class EventEmitter {
133
191
  return;
134
192
  return events[eventName];
135
193
  }
194
+ /** Release every subscription: both events this emitter listens to and listeners registered on it. */
136
195
  stopEvents() {
137
196
  this.stopListening();
138
197
  this.stopAllListeners();
139
198
  }
140
199
  }
141
200
 
201
+ /** Key used to track iteration/length dependencies (object key enumeration, array growth). */
202
+ const ITERATE_KEY = Symbol("iterate");
203
+ /**
204
+ * A reactive effect: a function whose reactive reads are tracked, so it re-runs
205
+ * whenever any of those reactive dependencies change.
206
+ */
207
+ class ReactiveEffect {
208
+ __fn;
209
+ /** Dependency sets this effect is currently subscribed to. @internal */
210
+ deps = [];
211
+ __active = true;
212
+ /** Called instead of `run` when a dependency changes (used by `computed`). */
213
+ scheduler;
214
+ /** Invoked once when the effect is stopped. */
215
+ onStop;
216
+ constructor(__fn, scheduler) {
217
+ this.__fn = __fn;
218
+ this.scheduler = scheduler;
219
+ activeScope?.add(this);
220
+ }
221
+ /** Whether the effect is still active (not stopped). */
222
+ get active() { return this.__active; }
223
+ /** Run the effect, (re)collecting its dependencies. */
224
+ run() {
225
+ if (!this.__active)
226
+ return this.__fn();
227
+ cleanupEffect(this);
228
+ const prevEffect = activeEffect;
229
+ activeEffect = this;
230
+ try {
231
+ return this.__fn();
232
+ }
233
+ finally {
234
+ activeEffect = prevEffect;
235
+ }
236
+ }
237
+ /** Stop the effect: remove its subscriptions and run `onStop`. */
238
+ stop() {
239
+ if (!this.__active)
240
+ return;
241
+ cleanupEffect(this);
242
+ this.__active = false;
243
+ this.onStop?.();
244
+ }
245
+ }
246
+ let activeEffect;
247
+ let activeScope;
248
+ const targetMap = new WeakMap();
249
+ /** Record the active effect (if any) as depending on `target[key]`. @internal */
250
+ function track(target, key) {
251
+ if (!activeEffect)
252
+ return;
253
+ let depsMap = targetMap.get(target);
254
+ if (!depsMap)
255
+ targetMap.set(target, depsMap = new Map());
256
+ let dep = depsMap.get(key);
257
+ if (!dep)
258
+ depsMap.set(key, dep = new Set());
259
+ if (!dep.has(activeEffect)) {
260
+ dep.add(activeEffect);
261
+ activeEffect.deps.push(dep);
262
+ }
263
+ }
264
+ /** Re-run (or schedule) every effect that depends on `target[key]`. @internal */
265
+ function trigger(target, key) {
266
+ const depsMap = targetMap.get(target);
267
+ if (!depsMap)
268
+ return;
269
+ const dep = depsMap.get(key);
270
+ if (!dep)
271
+ return;
272
+ // copy first: running an effect re-collects deps and would mutate the live set
273
+ const effects = new Set();
274
+ dep.forEach(e => { if (e !== activeEffect)
275
+ effects.add(e); });
276
+ effects.forEach(e => {
277
+ if (e.scheduler)
278
+ e.scheduler();
279
+ else
280
+ queueEffect(e);
281
+ });
282
+ }
283
+ const jobQueue = new Set();
284
+ const resolvedPromise = Promise.resolve();
285
+ let isFlushPending = false;
286
+ let currentFlush = null;
287
+ /** Queue an effect to run on the next microtask, deduplicated so multiple sync changes batch into one run. */
288
+ function queueEffect(effect) {
289
+ jobQueue.add(effect);
290
+ if (!isFlushPending) {
291
+ isFlushPending = true;
292
+ currentFlush = resolvedPromise.then(flushJobs);
293
+ }
294
+ }
295
+ function flushJobs() {
296
+ isFlushPending = false;
297
+ let guard = 0;
298
+ while (jobQueue.size) {
299
+ if (++guard > 10000) {
300
+ jobQueue.clear();
301
+ throw new Error("Reactive effect flush exceeded the iteration limit (cyclic update?).");
302
+ }
303
+ const jobs = Array.from(jobQueue);
304
+ jobQueue.clear();
305
+ for (const effect of jobs) {
306
+ if (effect.active)
307
+ effect.run();
308
+ }
309
+ }
310
+ currentFlush = null;
311
+ }
312
+ /**
313
+ * Resolves after the currently pending batch of reactive effects has flushed.
314
+ * Effect re-runs (and the DOM updates they drive) are batched on the microtask queue,
315
+ * so await `nextTick()` to observe their results.
316
+ */
317
+ function nextTick(fn) {
318
+ const p = currentFlush || resolvedPromise;
319
+ return fn ? p.then(fn).then(() => { }) : p.then(() => { });
320
+ }
321
+ /**
322
+ * Execute `fn` without tracking any reactive reads.
323
+ * Use inside a reactive context when you want to read state without creating a dependency.
324
+ */
325
+ function untrack(fn) {
326
+ const prev = activeEffect;
327
+ activeEffect = undefined;
328
+ try {
329
+ return fn();
330
+ }
331
+ finally {
332
+ activeEffect = prev;
333
+ }
334
+ }
335
+ function cleanupEffect(effect) {
336
+ const { deps } = effect;
337
+ deps.forEach(dep => dep.delete(effect));
338
+ deps.length = 0;
339
+ }
340
+ /** Create and immediately run a reactive effect. Returns the {@link ReactiveEffect}. */
341
+ function effect(fn, scheduler) {
342
+ const e = new ReactiveEffect(fn, scheduler);
343
+ e.run();
344
+ return e;
345
+ }
346
+ /** A disposable container that collects effects (and nested scopes) so they can be stopped together. */
347
+ class EffectScope {
348
+ __effects = [];
349
+ __scopes = [];
350
+ __active = true;
351
+ /** Whether the scope is still active (not stopped). */
352
+ get active() { return this.__active; }
353
+ /** @internal */
354
+ add(effect) { this.__effects.push(effect); }
355
+ /** @internal */
356
+ addScope(scope) { this.__scopes.push(scope); }
357
+ /** Run `fn` with this scope active; effects created during the call register to it. */
358
+ run(fn) {
359
+ const prev = activeScope;
360
+ activeScope = this;
361
+ try {
362
+ return fn();
363
+ }
364
+ finally {
365
+ activeScope = prev;
366
+ }
367
+ }
368
+ /** Stop all effects and nested scopes collected by this scope. */
369
+ stop() {
370
+ if (!this.__active)
371
+ return;
372
+ this.__active = false;
373
+ this.__effects.forEach(e => e.stop());
374
+ this.__effects.length = 0;
375
+ this.__scopes.forEach(s => s.stop());
376
+ this.__scopes.length = 0;
377
+ }
378
+ }
379
+ /** Create a new {@link EffectScope}, nested in the current scope if one is active. */
380
+ function effectScope() {
381
+ const scope = new EffectScope();
382
+ activeScope?.addScope(scope);
383
+ return scope;
384
+ }
385
+
386
+ const RAW = Symbol("raw");
387
+ const reactiveMap = new WeakMap();
388
+ function isObject(value) {
389
+ return value !== null && typeof value === "object";
390
+ }
391
+ function isIntegerKey(key) {
392
+ return typeof key === "string" && /^\d+$/.test(key);
393
+ }
394
+ /** Whether a value is a reactive proxy created by {@link reactive}. */
395
+ function isReactive(value) {
396
+ return isObject(value) && !!value[RAW];
397
+ }
398
+ /** Return the underlying raw (non-reactive) object behind a reactive proxy, or the value itself. */
399
+ function toRaw(value) {
400
+ const raw = isObject(value) && value[RAW];
401
+ return raw ? raw : value;
402
+ }
403
+ const handlers = {
404
+ get(target, key, receiver) {
405
+ if (key === RAW)
406
+ return target;
407
+ const result = Reflect.get(target, key, receiver);
408
+ // don't track symbol keys (well-known symbols, internal lookups)
409
+ if (typeof key === "symbol")
410
+ return result;
411
+ track(target, key);
412
+ // deep: wrap nested objects/arrays lazily on read
413
+ return isObject(result) ? reactive(result) : result;
414
+ },
415
+ set(target, key, value, receiver) {
416
+ const isArray = Array.isArray(target);
417
+ const isIndex = isArray && isIntegerKey(key);
418
+ const oldLength = isArray ? target.length : 0;
419
+ const hadKey = isIndex
420
+ ? Number(key) < oldLength
421
+ : Object.prototype.hasOwnProperty.call(target, key);
422
+ const oldValue = target[key];
423
+ const result = Reflect.set(target, key, toRaw(value), receiver);
424
+ if (!result)
425
+ return result;
426
+ if (!hadKey) {
427
+ trigger(target, key);
428
+ trigger(target, ITERATE_KEY);
429
+ }
430
+ else if (!Object.is(oldValue, toRaw(value))) {
431
+ trigger(target, key);
432
+ }
433
+ if (isArray && key === "length") {
434
+ // shrinking the array drops indices [newLength, oldLength) — notify
435
+ // effects that read them, plus iteration; growing only changes iteration
436
+ const newLength = Number(value);
437
+ for (let i = newLength; i < oldLength; i++)
438
+ trigger(target, String(i));
439
+ if (newLength !== oldLength)
440
+ trigger(target, ITERATE_KEY);
441
+ }
442
+ else if (isIndex && Number(key) >= oldLength) {
443
+ // a new index extended the array, so its length changed
444
+ trigger(target, "length");
445
+ }
446
+ return result;
447
+ },
448
+ deleteProperty(target, key) {
449
+ const hadKey = Object.prototype.hasOwnProperty.call(target, key);
450
+ const result = Reflect.deleteProperty(target, key);
451
+ if (hadKey && result) {
452
+ trigger(target, key);
453
+ trigger(target, ITERATE_KEY);
454
+ }
455
+ return result;
456
+ },
457
+ has(target, key) {
458
+ if (typeof key !== "symbol")
459
+ track(target, key);
460
+ return Reflect.has(target, key);
461
+ },
462
+ ownKeys(target) {
463
+ track(target, ITERATE_KEY);
464
+ return Reflect.ownKeys(target);
465
+ }
466
+ };
467
+ /**
468
+ * Wrap an object or array in a deep reactive proxy. Reads are tracked and writes
469
+ * notify effects. Returns the same proxy for the same target, and non-objects unchanged.
470
+ */
471
+ function reactive(target) {
472
+ if (!isObject(target))
473
+ return target;
474
+ if (target[RAW])
475
+ return target; // already reactive
476
+ const existing = reactiveMap.get(target);
477
+ if (existing)
478
+ return existing;
479
+ const proxy = new Proxy(target, handlers);
480
+ reactiveMap.set(target, proxy);
481
+ return proxy;
482
+ }
483
+
484
+ /** A lazily-evaluated, cached reactive value derived from other reactive state. */
485
+ class ComputedRef {
486
+ __value;
487
+ __dirty = true;
488
+ __effect;
489
+ constructor(getter) {
490
+ this.__effect = new ReactiveEffect(getter, () => {
491
+ // a dependency changed: mark dirty and notify effects that read this computed
492
+ if (!this.__dirty) {
493
+ this.__dirty = true;
494
+ trigger(this, "value");
495
+ }
496
+ });
497
+ }
498
+ /** The current value; recomputed on read only when its dependencies have changed. */
499
+ get value() {
500
+ if (this.__dirty) {
501
+ this.__value = this.__effect.run();
502
+ this.__dirty = false;
503
+ }
504
+ track(this, "value");
505
+ return this.__value;
506
+ }
507
+ }
508
+ /** Create a {@link ComputedRef} from a getter. */
509
+ function computed(getter) {
510
+ return new ComputedRef(getter);
511
+ }
512
+
513
+ // UIElements are located via the data attribute `UIElement.setElement` already sets
514
+ // (`elem.dataset.uiElement` → `data-ui-element`), so no extra marker is needed for them.
515
+ const UIELEM_SELECTOR = "[data-ui-element]";
516
+ // Marker placed on every element a reactive binding renders into, so a removed
517
+ // subtree can be queried for affected bindings instead of scanning all of them.
518
+ const BINDING_ATTR = "data-bui-binding";
519
+ const BINDING_SELECTOR = "[data-bui-binding]";
520
+ // node → UIElement auto-destroy entry
521
+ const trackedElements = new Map();
522
+ // container element → bindings rendered into it (one marker per container)
523
+ const bindingsByContainer = new Map();
524
+ let observer;
525
+ function ensureObserver() {
526
+ if (typeof MutationObserver !== "undefined" && !observer) {
527
+ observer = new MutationObserver(onMutations);
528
+ observer.observe(document, { childList: true, subtree: true });
529
+ }
530
+ }
531
+ function disconnectIfEmpty() {
532
+ if (trackedElements.size === 0 && bindingsByContainer.size === 0 && observer) {
533
+ observer.disconnect();
534
+ observer = undefined;
535
+ }
536
+ }
537
+ /**
538
+ * React only to *removed* nodes (insertions never disconnect anything), and within each
539
+ * removed subtree dispose only the tracked UIElements/bindings that ended up disconnected.
540
+ * Work is proportional to the removed subtree, not to the total number tracked.
541
+ */
542
+ function onMutations(mutations) {
543
+ for (const mutation of mutations) {
544
+ mutation.removedNodes.forEach(node => {
545
+ // Bindings/UIElements are tracked by their (element) container, so a removed
546
+ // text/comment node can neither be one nor contain one — only elements matter.
547
+ if (node instanceof HTMLElement)
548
+ disposeDisconnectedWithin(node);
549
+ });
550
+ }
551
+ disconnectIfEmpty();
552
+ }
553
+ /** Apply `fn` to `root` itself (when it matches) and every descendant matching `selector`. */
554
+ function forEachSelfAndMatches(root, selector, fn) {
555
+ if (root.matches(selector))
556
+ fn(root);
557
+ root.querySelectorAll(selector).forEach(el => fn(el));
558
+ }
559
+ /** Destroy/stop tracked entries inside a removed subtree that are no longer in the document. */
560
+ function disposeDisconnectedWithin(removed) {
561
+ // UIElements first: destroying one cascades to its nested UIElements and bindings.
562
+ forEachSelfAndMatches(removed, UIELEM_SELECTOR, el => {
563
+ // `isConnected` guards against moves (removed from one place, re-inserted in another).
564
+ if (!el.isConnected) {
565
+ const entry = trackedElements.get(el);
566
+ if (entry)
567
+ entry.destroy(); // destroy() untracks itself, cascades, and disposes its bindings
568
+ }
569
+ });
570
+ // Bindings not already cleaned by a UIElement destroy above (e.g. standalone containers).
571
+ forEachSelfAndMatches(removed, BINDING_SELECTOR, container => {
572
+ if (!container.isConnected)
573
+ stopContainerBindings(container);
574
+ });
575
+ }
576
+ function stopContainerBindings(container) {
577
+ const set = bindingsByContainer.get(container);
578
+ if (!set)
579
+ return;
580
+ bindingsByContainer.delete(container);
581
+ container.removeAttribute(BINDING_ATTR);
582
+ set.forEach(binding => binding.effect.stop());
583
+ }
584
+ /**
585
+ * Track a binding so its reactive effect is stopped automatically once the element it renders
586
+ * into has been mounted and then removed from the document (via a shared `MutationObserver`),
587
+ * and so {@link disposeBindingsWithin} can stop it when its owning UIElement is destroyed.
588
+ *
589
+ * Containers that are never mounted are not auto-disposed; the `MutationObserver` part is a
590
+ * no-op where it is unavailable, but `disposeBindingsWithin` still works.
591
+ *
592
+ * @param container Element the binding renders its node(s) into (its connectivity drives disposal).
593
+ * @param effect The reactive effect to stop on disposal.
594
+ */
595
+ function autoDisposeBinding(container, effect) {
596
+ let set = bindingsByContainer.get(container);
597
+ if (!set) {
598
+ bindingsByContainer.set(container, set = new Set());
599
+ container.setAttribute(BINDING_ATTR, "");
600
+ }
601
+ set.add({ container, effect });
602
+ ensureObserver();
603
+ }
604
+ /**
605
+ * Register a `UIElement`'s DOM node for auto-destroy: once the node has been mounted
606
+ * into the document and then removed, `destroy` is called automatically.
607
+ * @internal — called by `UIElement.setElement`.
608
+ */
609
+ function trackAutoDestroy(node, destroy) {
610
+ trackedElements.set(node, { node, destroy });
611
+ ensureObserver();
612
+ }
613
+ /**
614
+ * Remove a node from auto-destroy tracking (called when `UIElement.destroy` is
615
+ * invoked explicitly so the entry does not linger).
616
+ * @internal — called by `UIElement.destroy`.
617
+ */
618
+ function untrackAutoDestroy(node) {
619
+ trackedElements.delete(node);
620
+ disconnectIfEmpty();
621
+ }
622
+ /**
623
+ * Destroy every tracked `UIElement` nested within `root` (excluding `root` itself),
624
+ * deepest first. Located via the `data-ui-element` marker, so the cost is proportional
625
+ * to the subtree rather than to the total number of tracked elements.
626
+ * @internal — called by `UIElement.destroy`.
627
+ */
628
+ function destroyUIElementsWithin(root) {
629
+ const victims = [];
630
+ root.querySelectorAll(UIELEM_SELECTOR).forEach(el => {
631
+ if (trackedElements.has(el))
632
+ victims.push(el);
633
+ });
634
+ // querySelectorAll yields document order (ancestors before descendants);
635
+ // iterate in reverse so deeper / nested elements are destroyed first.
636
+ for (let i = victims.length - 1; i >= 0; i--)
637
+ trackedElements.get(victims[i])?.destroy();
638
+ }
639
+ /**
640
+ * Stop and forget every tracked binding rendered within `root` (used when a UIElement is
641
+ * destroyed). Unlike the observer path this is unconditional — it does not check connectivity,
642
+ * because the owner is being torn down.
643
+ */
644
+ function disposeBindingsWithin(root) {
645
+ if (root instanceof HTMLElement)
646
+ forEachSelfAndMatches(root, BINDING_SELECTOR, stopContainerBindings);
647
+ disconnectIfEmpty();
648
+ }
649
+
650
+ /** Default constant values used across the library. */
142
651
  const constants = {
143
652
  ElemAttributeName: "uiElement",
144
653
  ElemPropertyName: "uielement",
@@ -151,13 +660,24 @@ var constants$1 = /*#__PURE__*/Object.freeze({
151
660
  default: constants
152
661
  });
153
662
 
663
+ /**
664
+ * Wraps an `HTMLElement` and binds business logic, commands and events to it.
665
+ *
666
+ * The optional `TEvents` event map is merged with {@link UIElementEvents}, so subclasses
667
+ * can declare their own typed events in addition to the built-in `command`/`destroy`.
668
+ */
154
669
  class UIElement extends EventEmitter {
155
670
  __element;
156
671
  __events;
157
672
  __commands;
158
673
  __destroyed;
159
674
  // Element members
675
+ /** The bound DOM element, or `undefined` until `setElement` is called. */
160
676
  get element() { return this.__element; }
677
+ /**
678
+ * Bind a DOM element to this instance and run render logic. Can be called only once.
679
+ * @param elem Element to bind; throws if already bound or owned by another `UIElement`.
680
+ */
161
681
  setElement(elem) {
162
682
  if (!elem)
163
683
  throw new Error("Not set value elem.");
@@ -166,36 +686,56 @@ class UIElement extends EventEmitter {
166
686
  this.__element = elem;
167
687
  elem[constants.ElemPropertyName] = this;
168
688
  elem.dataset[constants.ElemAttributeName] = this.typeName;
689
+ trackAutoDestroy(elem, () => this.destroy());
169
690
  this._onRenderElement(elem);
691
+ this.__raise("rendered", this);
170
692
  }
171
693
  // static members
694
+ /**
695
+ * Whether the given element is already bound to a `UIElement`.
696
+ * @param elem Element to test.
697
+ */
172
698
  static hasElement(elem) {
173
699
  return !!elem.dataset[constants.ElemAttributeName];
174
700
  }
175
701
  // Command members
702
+ /**
703
+ * Register a handler for a command declared in markup via the `data-command` attribute.
704
+ * @param name Command name (case-insensitive); throws if already registered.
705
+ * @param execute Handler run when the command fires; may return a `Promise` for async commands.
706
+ * @param canExecute Optional predicate gating whether the command may run.
707
+ * @returns This instance for chaining.
708
+ */
176
709
  registerCommand(name, execute, canExecute) {
177
710
  if (this.__destroyed)
178
711
  return this;
179
712
  const commands = this.__commands || (this.__commands = {});
180
- const nornalizedName = name.toLowerCase();
181
- if (nornalizedName in commands)
713
+ const normalizedName = name.toLowerCase();
714
+ if (normalizedName in commands)
182
715
  throw new Error(`Command "${name}" already registered.`);
183
- commands[nornalizedName] = {
716
+ commands[normalizedName] = {
184
717
  name: name,
185
718
  execute,
186
719
  canExecute
187
720
  };
188
721
  return this;
189
722
  }
723
+ /**
724
+ * Whether a command with the given name is registered.
725
+ * @param name Command name (case-insensitive).
726
+ */
190
727
  hasCommand(name) {
191
- return this.__commands && name.toLowerCase() in this.__commands;
728
+ return !!this.__commands && name.toLowerCase() in this.__commands;
192
729
  }
193
- /** @internal */
730
+ /**
731
+ * Execute a registered command against a target element.
732
+ * @internal
733
+ */
194
734
  __execCommand(name, target) {
195
- if (this.__destroyed || !this.__element || !this.__commands)
196
- throw new Error("UIElement is not set HTMLElement.");
735
+ if (this.__destroyed || !this.__element)
736
+ throw new Error("UIElement is destroyed or has no element.");
197
737
  const key = name.toLowerCase();
198
- const command = this.__commands[key];
738
+ const command = this.__commands?.[key];
199
739
  if (!command)
200
740
  throw new Error(`Command "${name}" is not registered.`);
201
741
  const context = {
@@ -205,59 +745,75 @@ class UIElement extends EventEmitter {
205
745
  if (command.isExecuting)
206
746
  return { status: "already", context };
207
747
  command.isExecuting = true;
208
- if (!this._onCanExecCommand(name, target)) {
209
- delete command.isExecuting;
210
- return { status: "disallow", context };
211
- }
212
- if (command.canExecute && !command.canExecute(context)) {
213
- delete command.isExecuting;
214
- return { status: "disallow", context };
215
- }
216
- this.trigger("command", { element: this, name: command.name });
217
- let isAsync;
748
+ // keep isExecuting cleanup inside finally so a throw in the
749
+ // guards/trigger/execute can never leave the command stuck.
750
+ let isAsync = false;
218
751
  try {
752
+ if (!this._onCanExecCommand(name, target))
753
+ return { status: "disallow", context };
754
+ if (command.canExecute && !command.canExecute(context))
755
+ return { status: "disallow", context };
756
+ this.__raise("command", { element: this, name: command.name });
219
757
  const commandResult = command.execute(context);
220
- if (commandResult && commandResult instanceof Promise) {
758
+ if (commandResult instanceof Promise) {
221
759
  isAsync = true;
222
760
  target.classList.add(constants.CommandExecutingCssClassName);
223
761
  commandResult
762
+ // command owns its errors; log so failures aren't silent, and avoid unhandled rejection
763
+ .catch((reason) => console.error(`Command "${command.name}" failed.`, reason))
224
764
  .finally(() => {
225
765
  target.classList.remove(constants.CommandExecutingCssClassName);
226
766
  delete command.isExecuting;
227
767
  });
228
768
  }
769
+ return { status: "success", context };
229
770
  }
230
771
  finally {
231
772
  if (!isAsync)
232
773
  delete command.isExecuting;
233
774
  }
234
- return { status: "success", context: context };
775
+ }
776
+ /**
777
+ * Hook invoked when an element is bound via `setElement`. Override to render or wire up the element.
778
+ * @param _elem The newly bound element.
779
+ */
780
+ /** Trigger a built-in event. Typed by {@link UIElementEvents}; bypasses the generic trigger overload internally. */
781
+ __raise(name, ...args) {
782
+ this.trigger(name, ...args);
235
783
  }
236
784
  _onRenderElement(_elem) { }
785
+ /**
786
+ * Hook deciding whether a command may execute. Override to add element-wide gating.
787
+ * @param _name Command name being executed.
788
+ * @param _elem Target element of the command.
789
+ * @returns `true` to allow execution (default), `false` to disallow.
790
+ */
237
791
  _onCanExecCommand(_name, _elem) {
238
792
  return true;
239
793
  }
240
- onDestroy(callback) {
241
- if (!this.__element || !callback)
242
- return;
243
- if (callback instanceof UIElement)
244
- callback.listenTo(this, "destroy", () => callback.destroy());
245
- else if (callback instanceof Element)
246
- this.on("destroy", () => callback.remove());
247
- else if (typeof callback === "function")
248
- this.on("destroy", () => callback());
249
- else
250
- throw new Error("Unsupported callback type.");
794
+ /**
795
+ * Create an {@link EffectScope} whose reactive effects are stopped automatically when
796
+ * this element is destroyed. Use it to scope `bind`/`effect` to the element's lifetime.
797
+ */
798
+ effectScope() {
799
+ const scope = effectScope();
800
+ this.on("destroy", () => scope.stop());
801
+ return scope;
251
802
  }
803
+ /** Returns the `typeName` of this element. */
252
804
  toString() { return this.typeName; }
805
+ /** Destroy the element: trigger the `destroy` event, release events/commands and detach from the DOM element. */
253
806
  destroy() {
254
807
  if (this.__destroyed)
255
808
  return;
256
809
  this.__destroyed = true;
257
- this.trigger("destroy", this);
810
+ this.__raise("destroy", this);
258
811
  super.stopEvents();
259
812
  const elem = this.__element;
260
813
  if (elem) {
814
+ destroyUIElementsWithin(elem); // cascade to nested UIElements, deepest first
815
+ untrackAutoDestroy(elem);
816
+ disposeBindingsWithin(elem);
261
817
  delete elem.dataset[constants.ElemAttributeName];
262
818
  delete elem[constants.ElemPropertyName];
263
819
  }
@@ -266,6 +822,26 @@ class UIElement extends EventEmitter {
266
822
  delete this.__commands;
267
823
  }
268
824
  }
825
+ /**
826
+ * A {@link UIElement} whose DOM element is bound in the constructor, so its `element`
827
+ * is always defined (typed `HTMLElement`, never `undefined`). Subclasses pass their
828
+ * `typeName` and the element to `super`.
829
+ *
830
+ * Use {@link UIElement} directly when the element is bound later (e.g. an application
831
+ * that binds its element on run).
832
+ */
833
+ class UIElementBound extends UIElement {
834
+ __typeName;
835
+ /** @param typeName Unique type name of this element. @param elem Element to bind. */
836
+ constructor(typeName, elem) {
837
+ super();
838
+ this.__typeName = typeName; // set before setElement (no field-initializer race)
839
+ this.setElement(elem);
840
+ }
841
+ get typeName() { return this.__typeName; }
842
+ /** The bound DOM element; always defined since it is set in the constructor. */
843
+ get element() { return super.element; }
844
+ }
269
845
  const findUiElementByCommand = (elem, commandName) => {
270
846
  let current = elem;
271
847
  while (current) {
@@ -279,37 +855,578 @@ const findUiElementByCommand = (elem, commandName) => {
279
855
  return null;
280
856
  };
281
857
  const commandClickHandler = (e) => {
858
+ // walk up from the clicked element to the nearest ancestor declaring a command
282
859
  let commandElem = e.target;
283
- while (commandElem) {
284
- if (commandElem.dataset[constants.CommandAttributeName])
285
- break;
286
- if (commandElem === e.currentTarget)
287
- return;
860
+ while (commandElem && !commandElem.dataset[constants.CommandAttributeName])
288
861
  commandElem = commandElem.parentElement;
289
- }
290
862
  if (!commandElem)
291
863
  return;
292
864
  const commandName = commandElem.dataset[constants.CommandAttributeName];
293
865
  if (!commandName)
294
- throw new Error("Command data attribute is not have value.");
866
+ throw new Error("Command data attribute does not have a value.");
295
867
  const uiElem = findUiElementByCommand(commandElem, commandName);
296
- if (uiElem) {
297
- const result = uiElem.__execCommand(commandName, commandElem);
298
- if (result.status == "success" && result.context.transparent)
299
- return;
868
+ // A click on a data-command element is owned by the library: prevent the element's
869
+ // default action (e.g. <a> navigation) and stop the click chain — UNLESS the command
870
+ // completed successfully and asked to stay transparent. This runs in `finally`, so a
871
+ // throwing command can never skip preventDefault and let the page navigate/reload.
872
+ let transparent = false;
873
+ try {
874
+ if (uiElem) {
875
+ const result = uiElem.__execCommand(commandName, commandElem);
876
+ transparent = result.status === "success" && !!result.context.transparent;
877
+ }
878
+ else
879
+ console.warn(`Not find handler for command "${commandName}".`);
880
+ }
881
+ catch (reason) {
882
+ console.error(`Command "${commandName}" failed.`, reason);
883
+ }
884
+ finally {
885
+ if (!transparent) {
886
+ e.preventDefault();
887
+ e.stopPropagation();
888
+ e.stopImmediatePropagation();
889
+ }
890
+ }
891
+ };
892
+ // Guarded so importing the module does not throw in a non-DOM environment (SSR/Node).
893
+ if (typeof window !== "undefined")
894
+ window.addEventListener("click", commandClickHandler);
895
+ /** Remove the global click handler registered by brandup-ui. Call on app teardown or HMR disposal. */
896
+ function destroyUI() {
897
+ if (typeof window !== "undefined")
898
+ window.removeEventListener("click", commandClickHandler);
899
+ }
900
+
901
+ // Guarded so importing the module does not throw in a non-DOM environment (SSR/Node).
902
+ if (typeof HTMLElement !== "undefined") {
903
+ HTMLElement.prototype.ui = function (factory) {
904
+ factory(this);
905
+ return this;
906
+ };
907
+ }
908
+
909
+ /**
910
+ * A reactive binding for use as a {@link tag} child. Its `compute` function is
911
+ * re-evaluated and re-rendered whenever the reactive state it reads changes.
912
+ */
913
+ class Binding {
914
+ compute;
915
+ constructor(compute) {
916
+ this.compute = compute;
917
+ }
918
+ }
919
+ /**
920
+ * Create a reactive {@link Binding} that can be passed as a `tag` child. The
921
+ * compute function is tracked: it re-runs (updating the DOM in place) whenever
922
+ * any reactive value it reads changes.
923
+ *
924
+ * @example
925
+ * const state = reactive({ name: "Alice" });
926
+ * DOM.tag("div", null, "Hi, ", bind(() => state.name));
927
+ * state.name = "Bob"; // the text updates in place
928
+ */
929
+ function bind(compute) {
930
+ return new Binding(compute);
931
+ }
932
+
933
+ /** A keyed-list binding produced by {@link bindEach}; handled by `appendChild` in `tag.ts`. */
934
+ class BindingEach {
935
+ getItems;
936
+ getKey;
937
+ render;
938
+ constructor(getItems, getKey, render) {
939
+ this.getItems = getItems;
940
+ this.getKey = getKey;
941
+ this.render = render;
942
+ }
943
+ }
944
+ /**
945
+ * Create a reactive keyed-list binding for use as a {@link tag} child.
946
+ *
947
+ * The `getItems` function is tracked; the list is reconciled whenever the array
948
+ * changes (push, splice, reassignment, etc.). Items are matched by `getKey` so
949
+ * unchanged nodes stay in place — only new, removed, or reordered nodes are touched.
950
+ *
951
+ * `render` is called **once per key** and runs **untracked**. Use `bind()` inside
952
+ * the render function for item properties that should update independently:
953
+ *
954
+ * ⚠️ The item object passed to `render` is captured at first render for that key.
955
+ * Mutate items in place (`item.name = "..."`) so `bind()` reactions fire. Replacing
956
+ * the array with **new objects that reuse the same keys** keeps the cached node bound
957
+ * to the *old* object, so per-item `bind()`s won't update — change the key, or mutate
958
+ * the existing item, when its identity should change.
959
+ *
960
+ * @example
961
+ * DOM.tag("ul", null,
962
+ * bindEach(() => state.users, u => u.id, u =>
963
+ * DOM.tag("li", null, bind(() => u.name))
964
+ * )
965
+ * );
966
+ */
967
+ function bindEach(getItems, getKey, render) {
968
+ return new BindingEach(getItems, getKey, render);
969
+ }
970
+ /**
971
+ * Mount a {@link BindingEach} into `container` and start tracking.
972
+ * Called by `appendChild` in `tag.ts`; not intended for direct use.
973
+ * @internal
974
+ */
975
+ function appendBindingEach(container, binding) {
976
+ const nodes = new Map();
977
+ // Anchor comment marks the start of the managed region inside the container.
978
+ // Using an anchor (rather than container.firstChild) lets other children coexist.
979
+ const anchor = document.createComment("");
980
+ container.append(anchor);
981
+ const eff = effect(() => {
982
+ const items = binding.getItems();
983
+ const nextKeys = new Set();
984
+ for (let i = 0; i < items.length; i++)
985
+ nextKeys.add(binding.getKey(items[i], i));
986
+ // Remove nodes whose keys are no longer present
987
+ for (const [key, node] of nodes) {
988
+ if (!nextKeys.has(key)) {
989
+ node.remove();
990
+ nodes.delete(key);
991
+ }
992
+ }
993
+ // Insert new nodes and restore order in a single pass starting after the anchor.
994
+ // If the node is already at the expected position we advance; otherwise insertBefore moves it.
995
+ let cursor = anchor.nextSibling;
996
+ for (let i = 0; i < items.length; i++) {
997
+ const key = binding.getKey(items[i], i);
998
+ let node = nodes.get(key);
999
+ if (!node) {
1000
+ // Render untracked so item-property reads don't create dependencies on
1001
+ // this list effect — use bind() inside render for fine-grained updates.
1002
+ node = untrack(() => binding.render(items[i]));
1003
+ nodes.set(key, node);
1004
+ }
1005
+ if (cursor !== node)
1006
+ container.insertBefore(node, cursor);
1007
+ else
1008
+ cursor = cursor.nextSibling;
1009
+ }
1010
+ });
1011
+ autoDisposeBinding(container, eff);
1012
+ }
1013
+
1014
+ /**
1015
+ * @internal
1016
+ * Adds one or more CSS classes to a single element. A string is split on spaces. No-op when `cssClass` is falsy.
1017
+ */
1018
+ const addCssClass = (elem, cssClass) => {
1019
+ if (!cssClass)
1020
+ return;
1021
+ const tokens = (Array.isArray(cssClass) ? cssClass : cssClass.split(' ')).filter(Boolean);
1022
+ if (tokens.length)
1023
+ elem.classList.add(...tokens);
1024
+ };
1025
+ /**
1026
+ * @internal
1027
+ * Removes one or more CSS classes from a single element. A string is split on spaces. No-op when `cssClass` is falsy.
1028
+ */
1029
+ const removeCssClass = (elem, cssClass) => {
1030
+ if (!cssClass)
1031
+ return;
1032
+ const tokens = (Array.isArray(cssClass) ? cssClass : cssClass.split(' ')).filter(Boolean);
1033
+ if (tokens.length)
1034
+ elem.classList.remove(...tokens);
1035
+ };
1036
+ var helpers = {
1037
+ addCssClass,
1038
+ removeCssClass
1039
+ };
1040
+
1041
+ /**
1042
+ * Finds an element by its `id` within the whole document.
1043
+ * @param id The element id to look up.
1044
+ * @returns The matching element, or `null` if none exists.
1045
+ */
1046
+ function getById(id) {
1047
+ return document.getElementById(id);
1048
+ }
1049
+ /**
1050
+ * Returns the first descendant of `container` that has the given class.
1051
+ * @param container Element to search within.
1052
+ * @param className Single class name to match.
1053
+ * @returns The first matching element, or `null` if none found.
1054
+ */
1055
+ function getByClass(container, className) {
1056
+ const elements = container.getElementsByClassName(className);
1057
+ if (elements.length === 0)
1058
+ return null;
1059
+ return elements.item(0);
1060
+ }
1061
+ /**
1062
+ * Returns the first element in the document with the given `name` attribute.
1063
+ * @param name The `name` attribute value to look up.
1064
+ * @returns The first matching element, or `null` if none found.
1065
+ */
1066
+ function getByName(name) {
1067
+ const elements = document.getElementsByName(name);
1068
+ if (elements.length === 0)
1069
+ return null;
1070
+ return elements.item(0);
1071
+ }
1072
+ /**
1073
+ * Returns the first descendant of `container` with the given tag name.
1074
+ * @param container Element to search within.
1075
+ * @param tagName Tag name to match (e.g. `"input"`).
1076
+ * @returns The first matching element, or `null` if none found.
1077
+ */
1078
+ function getElementByTagName(container, tagName) {
1079
+ const elements = container.getElementsByTagName(tagName);
1080
+ if (elements.length === 0)
1081
+ return null;
1082
+ return elements.item(0);
1083
+ }
1084
+ /**
1085
+ * Returns all descendants of `container` with the given tag name as a live collection.
1086
+ * @param container Element to search within.
1087
+ * @param tagName Tag name to match (e.g. `"li"`).
1088
+ * @returns A live `HTMLCollection` of matching elements.
1089
+ */
1090
+ function getElementsByTagName(container, tagName) {
1091
+ return container.getElementsByTagName(tagName);
1092
+ }
1093
+ /**
1094
+ * Returns the first descendant of `container` matching the CSS selector.
1095
+ * @param container Element to search within.
1096
+ * @param query CSS selector.
1097
+ * @returns The first matching element, or `null` if none found.
1098
+ */
1099
+ function queryElement(container, query) {
1100
+ return container.querySelector(query);
1101
+ }
1102
+ /**
1103
+ * Returns all descendants of `container` matching the CSS selector.
1104
+ * @param container Element to search within.
1105
+ * @param query CSS selector.
1106
+ * @returns A static `NodeList` of matching elements.
1107
+ */
1108
+ function queryElements(container, query) {
1109
+ return container.querySelectorAll(query);
1110
+ }
1111
+ /**
1112
+ * Walks forward through the following siblings of `current` and returns the first one that has the given class.
1113
+ * @param current Element to start from.
1114
+ * @param className Class name to match.
1115
+ * @returns The first matching following sibling, or `null` if none found.
1116
+ */
1117
+ function nextElementByClass(current, className) {
1118
+ let elem = current.nextSibling;
1119
+ while (elem) {
1120
+ if (elem.nodeType === Node.ELEMENT_NODE && elem instanceof HTMLElement && elem.classList.contains(className))
1121
+ return elem;
1122
+ elem = elem.nextSibling;
1123
+ }
1124
+ return null;
1125
+ }
1126
+ /**
1127
+ * Walks backward through the preceding siblings of `current` and returns the first one that has the given class.
1128
+ * @param current Element to start from.
1129
+ * @param className Class name to match.
1130
+ * @returns The first matching preceding sibling, or `null` if none found.
1131
+ */
1132
+ function prevElementByClass(current, className) {
1133
+ let elem = current.previousSibling;
1134
+ while (elem) {
1135
+ if (elem.nodeType === Node.ELEMENT_NODE && elem instanceof HTMLElement && elem.classList.contains(className))
1136
+ return elem;
1137
+ elem = elem.previousSibling;
1138
+ }
1139
+ return null;
1140
+ }
1141
+ /**
1142
+ * Returns the nearest preceding sibling element of `current`, skipping non-element nodes (e.g. text nodes).
1143
+ * @param current Element to start from.
1144
+ * @returns The previous sibling element, or `null` if none found.
1145
+ */
1146
+ function prevElement(current) {
1147
+ let elem = current.previousSibling;
1148
+ while (elem) {
1149
+ if (elem.nodeType === Node.ELEMENT_NODE && elem instanceof HTMLElement)
1150
+ return elem;
1151
+ elem = elem.previousSibling;
1152
+ }
1153
+ return null;
1154
+ }
1155
+ /**
1156
+ * Returns the nearest following sibling element of `current`, skipping non-element nodes (e.g. text nodes).
1157
+ * @param current Element to start from.
1158
+ * @returns The next sibling element, or `null` if none found.
1159
+ */
1160
+ function nextElement(current) {
1161
+ let elem = current.nextSibling;
1162
+ while (elem) {
1163
+ if (elem.nodeType === Node.ELEMENT_NODE && elem instanceof HTMLElement)
1164
+ return elem;
1165
+ elem = elem.nextSibling;
1166
+ }
1167
+ return null;
1168
+ }
1169
+ /**
1170
+ * Adds the given CSS class(es) to every descendant of `container` matching the selector. No-op when `container` or `cssClass` is falsy.
1171
+ * @param container Element to search within (ignored when null/undefined).
1172
+ * @param selectors CSS selector for the elements to modify.
1173
+ * @param cssClass Class name(s) to add.
1174
+ */
1175
+ function addClass(container, selectors, cssClass) {
1176
+ if (!container || !cssClass)
1177
+ return;
1178
+ const nodes = container.querySelectorAll(selectors);
1179
+ nodes.forEach(node => helpers.addCssClass(node, cssClass));
1180
+ }
1181
+ /**
1182
+ * Removes the given CSS class(es) from every descendant of `container` matching the selector. No-op when `container` or `cssClass` is falsy.
1183
+ * @param container Element to search within (ignored when null/undefined).
1184
+ * @param selectors CSS selector for the elements to modify.
1185
+ * @param cssClass Class name(s) to remove.
1186
+ */
1187
+ function removeClass(container, selectors, cssClass) {
1188
+ if (!container || !cssClass)
1189
+ return;
1190
+ const nodes = container.querySelectorAll(selectors);
1191
+ nodes.forEach(elem => helpers.removeCssClass(elem, cssClass));
1192
+ }
1193
+ /**
1194
+ * Removes all child nodes from `container`, leaving it empty. No-op when `container` is null/undefined.
1195
+ * @param container Element to clear.
1196
+ */
1197
+ function empty(container) {
1198
+ if (!container)
1199
+ return;
1200
+ while (container.hasChildNodes()) {
1201
+ if (container.firstChild)
1202
+ container.removeChild(container.firstChild);
1203
+ }
1204
+ }
1205
+
1206
+ var DomHelpers = /*#__PURE__*/Object.freeze({
1207
+ __proto__: null,
1208
+ addClass: addClass,
1209
+ empty: empty,
1210
+ getByClass: getByClass,
1211
+ getById: getById,
1212
+ getByName: getByName,
1213
+ getElementByTagName: getElementByTagName,
1214
+ getElementsByTagName: getElementsByTagName,
1215
+ nextElement: nextElement,
1216
+ nextElementByClass: nextElementByClass,
1217
+ prevElement: prevElement,
1218
+ prevElementByClass: prevElementByClass,
1219
+ queryElement: queryElement,
1220
+ queryElements: queryElements,
1221
+ removeClass: removeClass
1222
+ });
1223
+
1224
+ /** Returns `true` when `value` should be treated as options (or absent options), `false` when it is the first child. */
1225
+ const isOptionsArg = (value) => {
1226
+ if (value === null || value === undefined)
1227
+ return true;
1228
+ if (typeof value !== "object")
1229
+ return false; // string, number, boolean, function → child
1230
+ if (Array.isArray(value))
1231
+ return false; // array → children
1232
+ if (value instanceof Element || value instanceof UIElement || value instanceof Binding || value instanceof BindingEach || value instanceof Promise)
1233
+ return false;
1234
+ return true; // plain object → ElementOptions
1235
+ };
1236
+ /**
1237
+ * Creates an HTML element, optionally applying options and appending children.
1238
+ *
1239
+ * The second argument is **options** when it is `null` or a plain {@link ElementOptions} object.
1240
+ * It is treated as the **first child** for any {@link TagFirstChild} value — strings, numbers,
1241
+ * elements, bindings, arrays, etc. — so `tag("div", "hello")` appends "hello" as HTML text,
1242
+ * and `tag("ul", bindEach(...))` works without a leading `null`.
1243
+ * To apply a CSS class use `{ class: "name" }` in options.
1244
+ */
1245
+ function tag(tagName, optionsOrChild, ...rest) {
1246
+ const elem = document.createElement(tagName);
1247
+ if (isOptionsArg(optionsOrChild)) {
1248
+ applyOptions(elem, optionsOrChild);
1249
+ appendChild(elem, rest);
1250
+ }
1251
+ else {
1252
+ appendChild(elem, optionsOrChild !== undefined ? [optionsOrChild, ...rest] : rest);
1253
+ }
1254
+ return elem;
1255
+ }
1256
+ /**
1257
+ * Applies element options to an existing element. A string or array is treated as a {@link CssClass}; otherwise each {@link ElementOptions} key is applied (`id`, `styles`, `class`, `command`, `dataset`, `events`, or a plain attribute). `undefined` values are skipped.
1258
+ * @param elem Target element to mutate.
1259
+ * @param options Options object, {@link CssClass} shorthand, or `null`/`undefined` for none.
1260
+ */
1261
+ const applyOptions = (elem, options) => {
1262
+ if (!options)
1263
+ return;
1264
+ if (typeof options === "string" || Array.isArray(options))
1265
+ helpers.addCssClass(elem, options);
1266
+ else {
1267
+ for (const key in options) {
1268
+ const value = options[key];
1269
+ if (value === undefined)
1270
+ continue;
1271
+ switch (key) {
1272
+ case "id":
1273
+ elem.id = value;
1274
+ break;
1275
+ case "styles": {
1276
+ if (value) {
1277
+ for (const sKey in value)
1278
+ elem.style[sKey] = value[sKey];
1279
+ }
1280
+ break;
1281
+ }
1282
+ case "class": {
1283
+ helpers.addCssClass(elem, value);
1284
+ break;
1285
+ }
1286
+ case "command": {
1287
+ elem.dataset["command"] = value;
1288
+ break;
1289
+ }
1290
+ case "dataset": {
1291
+ if (value) {
1292
+ for (const dataName in value)
1293
+ elem.dataset[dataName] = value[dataName];
1294
+ }
1295
+ break;
1296
+ }
1297
+ case "events": {
1298
+ if (value) {
1299
+ for (const eventName in value)
1300
+ elem.addEventListener(eventName, value[eventName]);
1301
+ }
1302
+ break;
1303
+ }
1304
+ default: {
1305
+ if (value === null)
1306
+ elem.setAttribute(key, "");
1307
+ else if (typeof value === "object")
1308
+ elem.setAttribute(key, JSON.stringify(value));
1309
+ else
1310
+ elem.setAttribute(key, String(value));
1311
+ break;
1312
+ }
1313
+ }
1314
+ }
300
1315
  }
301
- else
302
- console.warn(`Not find handler for command "${commandName}".`);
303
- e.preventDefault();
304
- e.stopPropagation();
305
- e.stopImmediatePropagation();
306
1316
  };
307
- window.addEventListener("click", commandClickHandler, false);
1317
+ /**
1318
+ * Appends one or more children to a container, recursively resolving arrays, promises and factory functions. Elements are appended as-is; a {@link UIElement} appends its bound element (or defers until `setElement` binds one); a reactive {@link Binding} renders and live-updates in place; strings/numbers/booleans are inserted as HTML; `null`/`undefined` are ignored.
1319
+ * @param container Element to append the children to.
1320
+ * @param children Child or children to append. See {@link TagChildrenLike}.
1321
+ * @throws Error When a child resolves to an unsupported type.
1322
+ */
1323
+ const appendChild = (container, children) => {
1324
+ if (children === null || children === undefined)
1325
+ return;
1326
+ if (children instanceof Array)
1327
+ children.forEach(child => appendChild(container, child));
1328
+ else if (children instanceof Element)
1329
+ container.append(children);
1330
+ else if (children instanceof UIElement) {
1331
+ if (children.element)
1332
+ container.append(children.element);
1333
+ else {
1334
+ // reserve the position now; replace the placeholder once setElement
1335
+ // binds the element (after _onRenderElement), keeping child order
1336
+ const placeholder = document.createComment("");
1337
+ container.append(placeholder);
1338
+ children.once("rendered", () => {
1339
+ if (children.element)
1340
+ placeholder.replaceWith(children.element);
1341
+ });
1342
+ }
1343
+ }
1344
+ else if (children instanceof Binding)
1345
+ appendBinding(container, children);
1346
+ else if (children instanceof BindingEach)
1347
+ appendBindingEach(container, children);
1348
+ else if (children instanceof Promise)
1349
+ children.then((child) => appendChild(container, child));
1350
+ else {
1351
+ const typeName = typeof children;
1352
+ let html;
1353
+ switch (typeName) {
1354
+ case "string":
1355
+ html = children;
1356
+ break;
1357
+ case "number":
1358
+ case "boolean":
1359
+ html = children.toString();
1360
+ break;
1361
+ case "function":
1362
+ const child = children(container);
1363
+ appendChild(container, child);
1364
+ return;
1365
+ default:
1366
+ throw new Error(`Not support child type of ${typeName}.`);
1367
+ }
1368
+ container.insertAdjacentHTML("beforeend", html);
1369
+ }
1370
+ };
1371
+ /**
1372
+ * Renders a reactive {@link Binding} child and keeps it up to date: a reactive
1373
+ * effect re-evaluates the binding and updates the DOM in place — reusing a text
1374
+ * node for text values and swapping the node when an element/UIElement is returned.
1375
+ */
1376
+ const appendBinding = (container, binding) => {
1377
+ let current = document.createTextNode("");
1378
+ let textNode = current;
1379
+ container.append(current);
1380
+ const eff = effect(() => {
1381
+ const value = binding.compute();
1382
+ if (value instanceof Element || value instanceof UIElement) {
1383
+ const next = (value instanceof UIElement ? value.element : value) ?? document.createComment("");
1384
+ current.replaceWith(next);
1385
+ current = next;
1386
+ textNode = null;
1387
+ // deferred UIElement: its element is bound later — swap the placeholder
1388
+ // for the real element once setElement raises "rendered"
1389
+ if (value instanceof UIElement && !value.element) {
1390
+ const placeholder = next;
1391
+ value.once("rendered", () => {
1392
+ // skip if the binding has since re-rendered to a different node
1393
+ if (value.element && current === placeholder) {
1394
+ placeholder.replaceWith(value.element);
1395
+ current = value.element;
1396
+ }
1397
+ });
1398
+ }
1399
+ }
1400
+ else {
1401
+ // null/undefined/false render as empty text
1402
+ const text = (value === null || value === undefined || value === false) ? "" : String(value);
1403
+ if (textNode && textNode === current) {
1404
+ textNode.textContent = text;
1405
+ }
1406
+ else {
1407
+ const next = document.createTextNode(text);
1408
+ current.replaceWith(next);
1409
+ current = next;
1410
+ textNode = next;
1411
+ }
1412
+ }
1413
+ });
1414
+ // stop the effect when the container is removed from the document
1415
+ autoDisposeBinding(container, eff);
1416
+ };
1417
+
1418
+ var TagHelpers = /*#__PURE__*/Object.freeze({
1419
+ __proto__: null,
1420
+ appendChild: appendChild,
1421
+ applyOptions: applyOptions,
1422
+ tag: tag
1423
+ });
308
1424
 
309
- HTMLElement.prototype.ui = function (factory) {
310
- factory(this);
311
- return this;
1425
+ /** Collection of DOM helper functions: element queries/traversal ({@link getById}, {@link queryElement}, {@link nextElement}, ...), class manipulation ({@link addClass}, {@link removeClass}), {@link empty}, and element creation via {@link tag}. */
1426
+ const DOM = {
1427
+ ...DomHelpers,
1428
+ ...TagHelpers
312
1429
  };
313
1430
 
314
- export { EventEmitter, constants$1 as UICONSTANTS, UIElement };
1431
+ export { Binding, BindingEach, ComputedRef, DOM, EffectScope, EventEmitter, ITERATE_KEY, ReactiveEffect, constants$1 as UICONSTANTS, UIElement, UIElementBound, appendBindingEach, bind, bindEach, computed, destroyUI, effect, effectScope, isReactive, nextTick, reactive, toRaw, track, trigger, untrack };
315
1432
  //# sourceMappingURL=index.js.map