@mintjamsinc/ichigojs 0.1.66 → 0.1.68

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.
@@ -7975,6 +7975,13 @@ class ReactiveProxy {
7975
7975
  * This allows retrieving the source path of an object for computed property mapping.
7976
7976
  */
7977
7977
  static proxyPaths = new WeakMap();
7978
+ /**
7979
+ * Dispatchers per (target, path). Every target reached while walking a proxy
7980
+ * subtree registers an entry pointing at the same dispatcher as the outermost
7981
+ * proxy of that subtree, so callers can look up the dispatcher from any
7982
+ * intermediate proxy when subscribing.
7983
+ */
7984
+ static dispatchers = new WeakMap();
7978
7985
  /**
7979
7986
  * A Map to store path aliases.
7980
7987
  * Key: alias path (e.g., "editingNestedStep.steps")
@@ -7989,9 +7996,10 @@ class ReactiveProxy {
7989
7996
  * @param target The object to make reactive.
7990
7997
  * @param onChange Callback function to call when the object changes. Receives the full path of the changed property.
7991
7998
  * @param path The current path in the object tree (used internally for nested objects).
7999
+ * @param inheritedDispatcher Internal: the dispatcher inherited from an enclosing create() call when wrapping a nested target. External callers must omit this.
7992
8000
  * @returns A reactive proxy of the target object.
7993
8001
  */
7994
- static create(target, onChange, path = '') {
8002
+ static create(target, onChange, path = '', inheritedDispatcher) {
7995
8003
  // If the target is not an object or is null, return it as-is
7996
8004
  if (typeof target !== 'object' || target === null) {
7997
8005
  return target;
@@ -8011,6 +8019,13 @@ class ReactiveProxy {
8011
8019
  if (this.proxyToTarget.has(target)) {
8012
8020
  return target;
8013
8021
  }
8022
+ // Resolve (and register) the dispatcher for this (target, path).
8023
+ // Nested create() calls inherit the dispatcher from the enclosing call so
8024
+ // that a single dispatcher fans out changes at any depth of the subtree.
8025
+ const dispatcher = this.resolveDispatcher(target, path, inheritedDispatcher);
8026
+ if (onChange) {
8027
+ dispatcher.listeners.add(onChange);
8028
+ }
8014
8029
  // Check if we already have a proxy for this target with this path
8015
8030
  let pathMap = this.proxyCache.get(target);
8016
8031
  if (pathMap) {
@@ -8042,7 +8057,7 @@ class ReactiveProxy {
8042
8057
  // Build the nested path
8043
8058
  const keyStr = String(key);
8044
8059
  const nestedPath = path ? (Array.isArray(obj) ? `${path}[${keyStr}]` : `${path}.${keyStr}`) : keyStr;
8045
- return ReactiveProxy.create(value, onChange, nestedPath);
8060
+ return ReactiveProxy.create(value, undefined, nestedPath, dispatcher);
8046
8061
  }
8047
8062
  // If the value is a function, we need to wrap it to ensure that any mutations it performs also trigger onChange
8048
8063
  if (typeof value === 'function') {
@@ -8054,7 +8069,7 @@ class ReactiveProxy {
8054
8069
  }
8055
8070
  return function (...args) {
8056
8071
  const result = value.apply(this === receiver ? obj : this, args);
8057
- onChange(path || undefined);
8072
+ ReactiveProxy.dispatch(dispatcher, path || undefined);
8058
8073
  return result;
8059
8074
  };
8060
8075
  }
@@ -8064,7 +8079,7 @@ class ReactiveProxy {
8064
8079
  return function (...args) {
8065
8080
  const result = value.apply(this === receiver ? obj : this, args);
8066
8081
  if (mapMutationMethods.includes(key)) {
8067
- onChange(path || undefined);
8082
+ ReactiveProxy.dispatch(dispatcher, path || undefined);
8068
8083
  }
8069
8084
  return result;
8070
8085
  };
@@ -8075,7 +8090,7 @@ class ReactiveProxy {
8075
8090
  return function (...args) {
8076
8091
  const result = value.apply(this === receiver ? obj : this, args);
8077
8092
  if (setMutationMethods.includes(key)) {
8078
- onChange(path || undefined);
8093
+ ReactiveProxy.dispatch(dispatcher, path || undefined);
8079
8094
  }
8080
8095
  return result;
8081
8096
  };
@@ -8090,7 +8105,7 @@ class ReactiveProxy {
8090
8105
  if (oldValue !== value) {
8091
8106
  const keyStr = String(key);
8092
8107
  const fullPath = path ? (Array.isArray(obj) ? `${path}[${keyStr}]` : `${path}.${keyStr}`) : keyStr;
8093
- onChange(fullPath);
8108
+ ReactiveProxy.dispatch(dispatcher, fullPath);
8094
8109
  }
8095
8110
  return result;
8096
8111
  },
@@ -8098,7 +8113,7 @@ class ReactiveProxy {
8098
8113
  const result = Reflect.deleteProperty(obj, key);
8099
8114
  const keyStr = String(key);
8100
8115
  const fullPath = path ? (Array.isArray(obj) ? `${path}[${keyStr}]` : `${path}.${keyStr}`) : keyStr;
8101
- onChange(fullPath);
8116
+ ReactiveProxy.dispatch(dispatcher, fullPath);
8102
8117
  return result;
8103
8118
  }
8104
8119
  });
@@ -8112,6 +8127,74 @@ class ReactiveProxy {
8112
8127
  }
8113
8128
  return proxy;
8114
8129
  }
8130
+ /**
8131
+ * Looks up the dispatcher associated with (target, path), or installs a new one.
8132
+ * When called for a nested target during proxy walking, the enclosing dispatcher
8133
+ * is reused so a single subtree fans out changes through one notification path.
8134
+ */
8135
+ static resolveDispatcher(target, path, inheritedDispatcher) {
8136
+ let pathMap = this.dispatchers.get(target);
8137
+ if (!pathMap) {
8138
+ pathMap = new Map();
8139
+ this.dispatchers.set(target, pathMap);
8140
+ }
8141
+ const existing = pathMap.get(path);
8142
+ if (existing) {
8143
+ return existing;
8144
+ }
8145
+ const dispatcher = inheritedDispatcher ?? { listeners: new Set() };
8146
+ pathMap.set(path, dispatcher);
8147
+ return dispatcher;
8148
+ }
8149
+ /**
8150
+ * Invokes every listener attached to the dispatcher.
8151
+ * Iterates a snapshot of the listener set so unsubscribing during dispatch is safe.
8152
+ */
8153
+ static dispatch(dispatcher, changedPath) {
8154
+ if (dispatcher.listeners.size === 0) {
8155
+ return;
8156
+ }
8157
+ const snapshot = Array.from(dispatcher.listeners);
8158
+ for (const listener of snapshot) {
8159
+ listener(changedPath);
8160
+ }
8161
+ }
8162
+ /**
8163
+ * Subscribes a listener to changes inside the subtree of an existing reactive proxy.
8164
+ *
8165
+ * The listener is scoped by the proxy's source path: only changes at or below that
8166
+ * path are delivered, which lets a child component receive notifications when the
8167
+ * nested contents of a prop change even though the prop reference itself is unchanged.
8168
+ *
8169
+ * @param proxyOrTarget A proxy returned from create(), or the underlying target object.
8170
+ * @param listener Called with the full source path of every relevant change.
8171
+ * @returns A function that removes the subscription.
8172
+ */
8173
+ static subscribe(proxyOrTarget, listener) {
8174
+ if (typeof proxyOrTarget !== 'object' || proxyOrTarget === null) {
8175
+ return () => { };
8176
+ }
8177
+ const target = (this.proxyToTarget.get(proxyOrTarget) ?? proxyOrTarget);
8178
+ const scopePath = this.proxyPaths.get(proxyOrTarget) ?? '';
8179
+ const pathMap = this.dispatchers.get(target);
8180
+ const dispatcher = pathMap?.get(scopePath);
8181
+ if (!dispatcher) {
8182
+ return () => { };
8183
+ }
8184
+ const wrapper = scopePath
8185
+ ? (changedPath) => {
8186
+ if (changedPath === scopePath ||
8187
+ (typeof changedPath === 'string' && (changedPath.startsWith(scopePath + '.') ||
8188
+ changedPath.startsWith(scopePath + '[')))) {
8189
+ listener(changedPath);
8190
+ }
8191
+ }
8192
+ : listener;
8193
+ dispatcher.listeners.add(wrapper);
8194
+ return () => {
8195
+ dispatcher.listeners.delete(wrapper);
8196
+ };
8197
+ }
8115
8198
  /**
8116
8199
  * Checks if the given object is a reactive proxy.
8117
8200
  *
@@ -8302,6 +8385,14 @@ class VBindings {
8302
8385
  * is updated by the recompute cycle through `setSilent`, which bypasses this routing.
8303
8386
  */
8304
8387
  #writableComputeds = new Map();
8388
+ /**
8389
+ * Unsubscribe handles for external reactive proxies received as binding values
8390
+ * (typically component props). Each entry keeps the child bindings notified when
8391
+ * nested properties of a shared object change even though the prop reference itself
8392
+ * stays the same. The previous subscription is released whenever the binding value
8393
+ * is replaced, the binding is set to null/undefined, or the bindings are destroyed.
8394
+ */
8395
+ #externalSubscriptions = new Map();
8305
8396
  /**
8306
8397
  * Creates a new instance of VBindings.
8307
8398
  * @param parent The parent bindings, if any.
@@ -8339,6 +8430,7 @@ class VBindings {
8339
8430
  }
8340
8431
  }
8341
8432
  let newValue = value;
8433
+ let receivedExternalProxy = false;
8342
8434
  if (typeof value === 'object' && value !== null) {
8343
8435
  // Check if the value already has a path (it's an existing reactive proxy reference)
8344
8436
  const existingPath = ReactiveProxy.getPath(value);
@@ -8348,6 +8440,7 @@ class VBindings {
8348
8440
  this.#logger?.debug(`Path alias registered: ${key} -> ${existingPath}`);
8349
8441
  // Keep the existing proxy as-is to preserve reactivity chain
8350
8442
  newValue = value;
8443
+ receivedExternalProxy = true;
8351
8444
  }
8352
8445
  else {
8353
8446
  // Before wrapping, check if any properties are existing ReactiveProxies
@@ -8391,6 +8484,34 @@ class VBindings {
8391
8484
  }
8392
8485
  const oldValue = Reflect.get(target, key);
8393
8486
  const result = Reflect.set(target, key, newValue);
8487
+ // Manage external subscription to a reactive proxy received as the value.
8488
+ // When the reference changes, drop the previous subscription. When a new
8489
+ // reactive proxy is installed, subscribe so nested changes propagate to
8490
+ // this bindings instance even though the proxy reference itself is stable.
8491
+ if (oldValue !== newValue) {
8492
+ const prevUnsubscribe = this.#externalSubscriptions.get(key);
8493
+ if (prevUnsubscribe) {
8494
+ prevUnsubscribe();
8495
+ this.#externalSubscriptions.delete(key);
8496
+ }
8497
+ }
8498
+ if (receivedExternalProxy && !this.#externalSubscriptions.has(key)) {
8499
+ const unsubscribe = ReactiveProxy.subscribe(newValue, (changedPath) => {
8500
+ if (!changedPath) {
8501
+ return;
8502
+ }
8503
+ let path = '';
8504
+ for (const part of changedPath.split('.')) {
8505
+ path = path ? `${path}.${part}` : part;
8506
+ this.#logger?.debug(`Binding changed (external): ${path}`);
8507
+ this.#changes.add(path);
8508
+ }
8509
+ if (!this.#suppressOnChange) {
8510
+ this.#onChange?.(changedPath);
8511
+ }
8512
+ });
8513
+ this.#externalSubscriptions.set(key, unsubscribe);
8514
+ }
8394
8515
  // Detect changes
8395
8516
  let hasChanged = oldValue !== newValue;
8396
8517
  // Special handling for arrays: check length changes even if same object reference
@@ -8419,6 +8540,11 @@ class VBindings {
8419
8540
  },
8420
8541
  deleteProperty: (obj, key) => {
8421
8542
  const result = Reflect.deleteProperty(obj, key);
8543
+ const prevUnsubscribe = this.#externalSubscriptions.get(key);
8544
+ if (prevUnsubscribe) {
8545
+ prevUnsubscribe();
8546
+ this.#externalSubscriptions.delete(key);
8547
+ }
8422
8548
  this.#logger?.debug(`Binding deleted: ${key}`);
8423
8549
  this.#changes.add(key);
8424
8550
  this.#onChange?.(key);
@@ -8500,6 +8626,18 @@ class VBindings {
8500
8626
  remove(key) {
8501
8627
  delete this.#local[key];
8502
8628
  }
8629
+ /**
8630
+ * Releases all external proxy subscriptions held by these bindings.
8631
+ * Should be called when the owning application is unmounted so the parent
8632
+ * application's reactive objects do not keep references to listener closures
8633
+ * (and through them, this bindings instance) alive.
8634
+ */
8635
+ destroy() {
8636
+ for (const unsubscribe of this.#externalSubscriptions.values()) {
8637
+ unsubscribe();
8638
+ }
8639
+ this.#externalSubscriptions.clear();
8640
+ }
8503
8641
  /**
8504
8642
  * Sets a binding value without triggering onChange callback.
8505
8643
  * This is useful for internal updates that shouldn't trigger reactivity.
@@ -11531,6 +11669,15 @@ class VModelDirective {
11531
11669
  * Mouse button modifiers (MouseEvent): `.left`, `.middle`, `.right`.
11532
11670
  * System modifiers (KeyboardEvent and MouseEvent): `.shift`, `.ctrl`, `.alt`, `.meta`, plus `.exact` to require that no other system modifiers are held.
11533
11671
  *
11672
+ * Listen target and filter modifiers (two orthogonal axes):
11673
+ * - Listen target (where the listener is attached): `.window`, `.document`. When omitted the listener
11674
+ * is attached to the bound element. This is useful for global / cross-component events, e.g.
11675
+ * `@webtop-message.document="onMessage"`, and the listener is removed automatically on unmount.
11676
+ * - Filter (whether the handler runs): `.self` fires only when `event.target` is the bound element;
11677
+ * `.outside` fires only when `event.target` is outside the bound element (e.g. click-outside to
11678
+ * close a popup). `.outside` implies listening on `document` (capture phase) even without `.document`,
11679
+ * and `.self` / `.outside` are mutually exclusive.
11680
+ *
11534
11681
  * Additionally, this directive supports lifecycle hooks:
11535
11682
  * @mount="onMount" - Called before the VNode is mounted to the DOM element
11536
11683
  * @mounted="onMounted" - Called after the VNode is mounted to the DOM element
@@ -11569,6 +11716,26 @@ class VOnDirective {
11569
11716
  * The event listener function for DOM events.
11570
11717
  */
11571
11718
  #listener;
11719
+ /**
11720
+ * The resolved target the listener is attached to (element, document, or window).
11721
+ * Stored so destroy() removes the listener from the same target it was added to.
11722
+ */
11723
+ #resolvedTarget;
11724
+ /**
11725
+ * The resolved capture flag. Shared by attach and destroy so they stay in sync,
11726
+ * since `.outside` forces capture phase regardless of the `.capture` modifier.
11727
+ */
11728
+ #useCapture = false;
11729
+ /**
11730
+ * Whether the listener has actually been attached. `.outside` defers attachment by a
11731
+ * microtask, so destroy() must not attempt removal before it is attached.
11732
+ */
11733
+ #attached = false;
11734
+ /**
11735
+ * Whether the directive has been destroyed. Guards the deferred `.outside` attachment
11736
+ * from attaching after the node was already unmounted.
11737
+ */
11738
+ #destroyed = false;
11572
11739
  /**
11573
11740
  * Map of lifecycle hook names to their handler functions.
11574
11741
  */
@@ -11592,6 +11759,12 @@ class VOnDirective {
11592
11759
  this.#eventName = parts[0];
11593
11760
  parts.slice(1).forEach(mod => this.#modifiers.add(mod));
11594
11761
  }
11762
+ // `.self` and `.outside` are mutually exclusive filters; together they can never fire.
11763
+ if (this.#modifiers.has('self') && this.#modifiers.has('outside')) {
11764
+ context.vNode.vApplication.logManager
11765
+ .getLogger('VOnDirective')
11766
+ .warn(`The '.self' and '.outside' modifiers on '${attrName}' are mutually exclusive; the handler will never fire.`);
11767
+ }
11595
11768
  // Parse the expression to extract identifiers and create the handler wrapper.
11596
11769
  // Event handlers are parsed in script mode so that users can write multi-statement bodies
11597
11770
  // (e.g. "a=1; b=2"), declarations, and control-flow constructs — matching Vue semantics.
@@ -11701,11 +11874,10 @@ class VOnDirective {
11701
11874
  * @inheritdoc
11702
11875
  */
11703
11876
  destroy() {
11704
- // Remove the event listener when the directive is destroyed
11705
- if (this.#eventName && this.#listener) {
11706
- const element = this.#vNode.node;
11707
- const useCapture = this.#modifiers.has('capture');
11708
- element.removeEventListener(this.#eventName, this.#listener, useCapture);
11877
+ this.#destroyed = true;
11878
+ // Remove the event listener from the same target/phase it was attached to.
11879
+ if (this.#eventName && this.#listener && this.#resolvedTarget && this.#attached) {
11880
+ this.#resolvedTarget.removeEventListener(this.#eventName, this.#listener, this.#useCapture);
11709
11881
  }
11710
11882
  }
11711
11883
  /**
@@ -11721,8 +11893,21 @@ class VOnDirective {
11721
11893
  }
11722
11894
  const element = this.#vNode.node;
11723
11895
  const eventName = this.#eventName;
11724
- const useCapture = this.#modifiers.has('capture');
11725
11896
  const isOnce = this.#modifiers.has('once');
11897
+ const isOutside = this.#modifiers.has('outside');
11898
+ // Resolve the listen target (orthogonal to filters): `.window` / `.document` attach
11899
+ // the listener globally; `.outside` also requires a global listener to detect events
11900
+ // originating outside the element, so it implies `document`.
11901
+ this.#resolvedTarget = this.#modifiers.has('window')
11902
+ ? window
11903
+ : (this.#modifiers.has('document') || isOutside)
11904
+ ? document
11905
+ : element;
11906
+ // `.outside` listens in capture phase so it is not suppressed by a descendant's
11907
+ // stopPropagation(); otherwise the capture flag follows the `.capture` modifier.
11908
+ this.#useCapture = this.#modifiers.has('capture') || isOutside;
11909
+ const useCapture = this.#useCapture;
11910
+ const target = this.#resolvedTarget;
11726
11911
  // System modifier keys (held during the event) shared by KeyboardEvent and MouseEvent.
11727
11912
  const systemModifiers = ['shift', 'ctrl', 'alt', 'meta'];
11728
11913
  // Create the event listener function
@@ -11808,6 +11993,14 @@ class VOnDirective {
11808
11993
  if (this.#modifiers.has('self') && event.target !== element) {
11809
11994
  return;
11810
11995
  }
11996
+ // `.outside`: only fire when the event originates outside the bound element.
11997
+ // A non-Node target (e.g. window) is treated as outside.
11998
+ if (isOutside) {
11999
+ const eventTarget = event.target;
12000
+ if (eventTarget instanceof Node && element.contains(eventTarget)) {
12001
+ return;
12002
+ }
12003
+ }
11811
12004
  // Call the pre-generated handler wrapper (if exists)
11812
12005
  if (this.#handlerWrapper) {
11813
12006
  this.#handlerWrapper(event);
@@ -11816,11 +12009,26 @@ class VOnDirective {
11816
12009
  // No need to manually call scheduleUpdate() here
11817
12010
  // If 'once' modifier is used, remove the listener after first execution
11818
12011
  if (isOnce && this.#listener) {
11819
- element.removeEventListener(eventName, this.#listener, useCapture);
12012
+ target.removeEventListener(eventName, this.#listener, useCapture);
12013
+ this.#attached = false;
11820
12014
  }
11821
12015
  };
11822
- // Attach the event listener
11823
- element.addEventListener(eventName, this.#listener, useCapture);
12016
+ if (isOutside) {
12017
+ // Defer attachment by one microtask so the listener does not catch the same
12018
+ // interaction that mounted this element (e.g. the click that opened a popup,
12019
+ // which would otherwise immediately close it). Skip if already destroyed.
12020
+ queueMicrotask(() => {
12021
+ if (this.#destroyed || !this.#listener) {
12022
+ return;
12023
+ }
12024
+ target.addEventListener(eventName, this.#listener, useCapture);
12025
+ this.#attached = true;
12026
+ });
12027
+ }
12028
+ else {
12029
+ target.addEventListener(eventName, this.#listener, useCapture);
12030
+ this.#attached = true;
12031
+ }
11824
12032
  }
11825
12033
  /**
11826
12034
  * Checks if the event name is a lifecycle hook.
@@ -13504,6 +13712,7 @@ class VApplication {
13504
13712
  this.#vNode.destroy();
13505
13713
  this.#vNode = undefined;
13506
13714
  }
13715
+ this.#bindings?.destroy();
13507
13716
  this.#logger.info('Application unmounted.');
13508
13717
  }
13509
13718
  /**
@@ -13604,6 +13813,7 @@ class VApplication {
13604
13813
  // Inject utility methods into bindings
13605
13814
  this.#bindings.set('$nextTick', (callback) => this.#nextTick(callback));
13606
13815
  this.#bindings.set('$markRaw', (obj) => ReactiveProxy.markRaw(obj));
13816
+ this.#bindings.set('$emit', (name, detail, options) => this.#emit(name, detail, options));
13607
13817
  // Add methods
13608
13818
  if (this.#options.methods) {
13609
13819
  for (const [key, method] of Object.entries(this.#options.methods)) {
@@ -13849,6 +14059,38 @@ class VApplication {
13849
14059
  compute(key);
13850
14060
  }
13851
14061
  }
14062
+ /**
14063
+ * Dispatches a CustomEvent, providing the framework-level `$emit` available in expressions
14064
+ * and methods. By default the event is dispatched on the application root element with
14065
+ * `bubbles: true`, so a parent component can listen for it via `v-on` / `@` on the component
14066
+ * tag (the root is rendered inside the host custom element, so the event bubbles out of it).
14067
+ *
14068
+ * The dispatch target can be overridden via `options.target` (e.g. `document` / `window`) to
14069
+ * use a global event bus, interoperating with native `addEventListener` listeners.
14070
+ *
14071
+ * @param name The event name (e.g. "selected"). Listened to as `@selected` on the parent side.
14072
+ * @param detail The payload exposed as `event.detail`.
14073
+ * @param options Dispatch options (bubbles, cancelable, composed, target).
14074
+ * @returns The result of dispatchEvent: false if a listener called preventDefault(), otherwise true.
14075
+ */
14076
+ #emit(name, detail, options) {
14077
+ // Documentation/validation only: warn when emitting an event not declared in `emits`.
14078
+ if (this.#options.emits && !this.#options.emits.includes(name)) {
14079
+ this.#logger.warn(`Event '${name}' is emitted but not declared in the 'emits' option.`);
14080
+ }
14081
+ const target = options?.target ?? this.#vNode?.node;
14082
+ if (!target) {
14083
+ this.#logger.warn(`$emit('${name}') was called before the application was mounted; the event was not dispatched.`);
14084
+ return false;
14085
+ }
14086
+ const event = new CustomEvent(name, {
14087
+ detail,
14088
+ bubbles: options?.bubbles ?? true,
14089
+ cancelable: options?.cancelable ?? true,
14090
+ composed: options?.composed ?? false,
14091
+ });
14092
+ return target.dispatchEvent(event);
14093
+ }
13852
14094
  /**
13853
14095
  * Executes a callback after the next DOM update.
13854
14096
  * @param callback The callback to execute.
@@ -14141,7 +14383,7 @@ class IchigoElement extends HTMLElement {
14141
14383
  * @param options Component options including template selector and optional props.
14142
14384
  */
14143
14385
  function defineComponent(tagName, options) {
14144
- const { props = [], template, data, computed, methods, watch, logLevel } = options;
14386
+ const { props = [], template, data, computed, methods, watch, emits, logLevel } = options;
14145
14387
  // Build a subclass of IchigoElement specific to this component
14146
14388
  class ComponentElement extends IchigoElement {
14147
14389
  static _template = template;
@@ -14163,6 +14405,7 @@ function defineComponent(tagName, options) {
14163
14405
  computed,
14164
14406
  methods,
14165
14407
  watch,
14408
+ emits,
14166
14409
  logLevel,
14167
14410
  };
14168
14411
  }