@mintjamsinc/ichigojs 0.1.75 → 0.1.77

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/ichigo.cjs CHANGED
@@ -7511,6 +7511,14 @@
7511
7511
  * @inheritdoc
7512
7512
  */
7513
7513
  get onMount() {
7514
+ // A constant expression (e.g. :label="'Apple'") has no reactive
7515
+ // identifiers, so the identifier-driven update cycle never applies it.
7516
+ // Render it once at mount instead.
7517
+ if (this.#evaluator && this.#evaluator.dependentIdentifiers.length === 0) {
7518
+ return () => {
7519
+ this.#render();
7520
+ };
7521
+ }
7514
7522
  return undefined;
7515
7523
  }
7516
7524
  /**
@@ -8926,6 +8934,344 @@
8926
8934
  }
8927
8935
  }
8928
8936
 
8937
+ // Copyright (c) 2025 MintJams Inc. Licensed under MIT License.
8938
+ /**
8939
+ * Registry of custom element tag names defined via defineComponent().
8940
+ *
8941
+ * VNode consults this registry during compilation to recognize component
8942
+ * boundaries: the children of an already-expanded ichigo component belong to
8943
+ * the component's own VApplication and must not be compiled by the parent
8944
+ * application (doing so would strip the component's directives and evaluate
8945
+ * its template expressions in the wrong scope).
8946
+ */
8947
+ class IchigoElementRegistry {
8948
+ /**
8949
+ * Registered tag names, stored uppercased to match Node.nodeName.
8950
+ */
8951
+ static #tags = new Set();
8952
+ /**
8953
+ * Registers a component tag name. Called by defineComponent().
8954
+ * @param tagName The custom element tag name (case-insensitive).
8955
+ */
8956
+ static add(tagName) {
8957
+ this.#tags.add(tagName.toUpperCase());
8958
+ }
8959
+ /**
8960
+ * Checks whether a tag name belongs to a component defined via defineComponent().
8961
+ * @param tagName The tag name to check (case-insensitive; nodeName is accepted as-is).
8962
+ */
8963
+ static has(tagName) {
8964
+ return this.#tags.has(tagName.toUpperCase());
8965
+ }
8966
+ }
8967
+
8968
+ // Copyright (c) 2025 MintJams Inc. Licensed under MIT License.
8969
+ /**
8970
+ * Renders a scoped slot outlet: a `<slot>` element inside a component template
8971
+ * whose host element received a matching `<template v-slot:name="scope">` from
8972
+ * the parent.
8973
+ *
8974
+ * The slot content is compiled in the PARENT application's scope (the scope in
8975
+ * which the template was authored), extended with the slot props — the values
8976
+ * bound on the `<slot>` outlet with `v-bind` / `:` — exposed under the single
8977
+ * scope variable declared in `v-slot` (e.g. `v-slot:item="row"` makes
8978
+ * `row.node`, `row.index`, ... available):
8979
+ *
8980
+ * ```html
8981
+ * <!-- component template -->
8982
+ * <li v-for="(node, i) of items">
8983
+ * <slot name="item" :node="node" :index="i">fallback</slot>
8984
+ * </li>
8985
+ *
8986
+ * <!-- usage -->
8987
+ * <my-tree :items="nodes">
8988
+ * <template v-slot:item="row">{{ row.node.label }} ({{ appTitle }})</template>
8989
+ * </my-tree>
8990
+ * ```
8991
+ *
8992
+ * This directive is created only when a scoped template for the slot's name
8993
+ * exists (see {@link VSlotOutletDirective.tryCreate}); otherwise `<slot>`
8994
+ * elements keep their existing behavior (static distribution at expansion,
8995
+ * or fallback content compiled in the component scope).
8996
+ *
8997
+ * Rendering happens synchronously in onMount — before the component
8998
+ * application would compile the slot's fallback children — by replacing the
8999
+ * `<slot>` element with a clone of the scoped template's content. The content
9000
+ * VNode is attached under the host element's VNode in the parent application,
9001
+ * so parent-side data changes flow through the normal update cycle; slot-prop
9002
+ * changes on the component side are pushed by this directive's domUpdater.
9003
+ */
9004
+ class VSlotOutletDirective {
9005
+ /**
9006
+ * The virtual node of the `<slot>` element (in the component application).
9007
+ */
9008
+ #vNode;
9009
+ /**
9010
+ * The captured scoped template and its scope variable name.
9011
+ */
9012
+ #entry;
9013
+ /**
9014
+ * The host element's VNode in the parent application. Slot content is
9015
+ * compiled against this node's application and bindings.
9016
+ */
9017
+ #hostVNode;
9018
+ /**
9019
+ * The slot props: camelized names and evaluators for the `v-bind` / `:`
9020
+ * attributes found on the `<slot>` outlet (evaluated in component scope).
9021
+ */
9022
+ #props = [];
9023
+ /**
9024
+ * The bindings for the slot content: a child scope of the parent
9025
+ * application's bindings holding the scope variable.
9026
+ */
9027
+ #scopeBindings;
9028
+ /**
9029
+ * The VNode tree of the rendered slot content (owned by the parent application).
9030
+ */
9031
+ #contentVNode;
9032
+ /**
9033
+ * Guards against double destruction (the content VNode is reachable from
9034
+ * both the parent application's tree and this directive).
9035
+ */
9036
+ #destroyed = false;
9037
+ /**
9038
+ * Tracks host elements that already logged the "no host context" warning.
9039
+ */
9040
+ static #warnedNoHost = new WeakSet();
9041
+ /**
9042
+ * Creates a slot outlet directive for the given `<slot>` VNode when a
9043
+ * scoped template for its slot name was captured on the host element.
9044
+ * Returns undefined when there is nothing to do (no component host, no
9045
+ * scoped template for this name) so the `<slot>` element keeps its
9046
+ * existing behavior.
9047
+ */
9048
+ static tryCreate(vNode) {
9049
+ const host = vNode.vApplication.hostElement;
9050
+ if (!host) {
9051
+ return undefined;
9052
+ }
9053
+ const scopedSlots = host._ichigoScopedSlots;
9054
+ if (!scopedSlots || scopedSlots.size === 0) {
9055
+ return undefined;
9056
+ }
9057
+ const element = vNode.node;
9058
+ const slotName = element.getAttribute('name') || 'default';
9059
+ const entry = scopedSlots.get(slotName);
9060
+ if (!entry) {
9061
+ return undefined;
9062
+ }
9063
+ const hostVNode = host._ichigoHost?.vNode;
9064
+ if (!hostVNode) {
9065
+ // The scoped template cannot be rendered without the parent scope.
9066
+ // Leave the <slot> alone (fallback content renders in component scope).
9067
+ if (!VSlotOutletDirective.#warnedNoHost.has(host)) {
9068
+ VSlotOutletDirective.#warnedNoHost.add(host);
9069
+ console.warn(`[ichigo] <${host.tagName.toLowerCase()}>: a scoped slot template for '${slotName}' was provided, but the component has no parent application scope to render it in. The fallback content is used instead.`);
9070
+ }
9071
+ return undefined;
9072
+ }
9073
+ return new VSlotOutletDirective(vNode, entry, hostVNode);
9074
+ }
9075
+ constructor(vNode, entry, hostVNode) {
9076
+ this.#vNode = vNode;
9077
+ this.#entry = entry;
9078
+ this.#hostVNode = hostVNode;
9079
+ // Collect and consume the slot-prop bindings (v-bind / :) so the
9080
+ // generic attribute parsing does not also process them.
9081
+ const element = vNode.node;
9082
+ for (const attr of Array.from(element.attributes)) {
9083
+ let propName;
9084
+ if (attr.name.startsWith('v-bind:')) {
9085
+ propName = attr.name.substring(7);
9086
+ }
9087
+ else if (attr.name.startsWith(':')) {
9088
+ propName = attr.name.substring(1);
9089
+ }
9090
+ if (!propName) {
9091
+ continue;
9092
+ }
9093
+ const evaluator = attr.value && vNode.bindings
9094
+ ? ExpressionEvaluator.create(attr.value, vNode.bindings, vNode.vApplication.functionDependencies)
9095
+ : undefined;
9096
+ this.#props.push({ name: this.#kebabToCamel(propName), evaluator });
9097
+ element.removeAttribute(attr.name);
9098
+ }
9099
+ }
9100
+ /**
9101
+ * @inheritdoc
9102
+ */
9103
+ get name() {
9104
+ return 'slot-outlet';
9105
+ }
9106
+ /**
9107
+ * @inheritdoc
9108
+ */
9109
+ get vNode() {
9110
+ return this.#vNode;
9111
+ }
9112
+ /**
9113
+ * @inheritdoc
9114
+ */
9115
+ get needsAnchor() {
9116
+ return false;
9117
+ }
9118
+ /**
9119
+ * @inheritdoc
9120
+ */
9121
+ get bindingsPreparer() {
9122
+ return undefined;
9123
+ }
9124
+ /**
9125
+ * @inheritdoc
9126
+ */
9127
+ get domUpdater() {
9128
+ const identifiers = this.dependentIdentifiers;
9129
+ const updater = {
9130
+ get dependentIdentifiers() {
9131
+ return identifiers;
9132
+ },
9133
+ applyToDOM: () => {
9134
+ this.#refresh();
9135
+ }
9136
+ };
9137
+ return updater;
9138
+ }
9139
+ /**
9140
+ * @inheritdoc
9141
+ */
9142
+ get templatize() {
9143
+ return false;
9144
+ }
9145
+ /**
9146
+ * @inheritdoc
9147
+ */
9148
+ get dependentIdentifiers() {
9149
+ const ids = new Set();
9150
+ for (const prop of this.#props) {
9151
+ prop.evaluator?.dependentIdentifiers.forEach(id => ids.add(id));
9152
+ }
9153
+ return Array.from(ids);
9154
+ }
9155
+ /**
9156
+ * @inheritdoc
9157
+ *
9158
+ * The initial render happens here — synchronously during VNode
9159
+ * construction, after the directives are created and BEFORE the component
9160
+ * application compiles the slot element's children. This guarantees the
9161
+ * outlet renders exactly once regardless of whether its slot props have
9162
+ * reactive identifiers, and removes the fallback children before they
9163
+ * could be compiled in the component scope.
9164
+ */
9165
+ get onMount() {
9166
+ return () => {
9167
+ this.#render();
9168
+ };
9169
+ }
9170
+ /**
9171
+ * @inheritdoc
9172
+ */
9173
+ get onMounted() {
9174
+ return undefined;
9175
+ }
9176
+ /**
9177
+ * @inheritdoc
9178
+ */
9179
+ get onUpdate() {
9180
+ return undefined;
9181
+ }
9182
+ /**
9183
+ * @inheritdoc
9184
+ */
9185
+ get onUpdated() {
9186
+ return undefined;
9187
+ }
9188
+ /**
9189
+ * @inheritdoc
9190
+ */
9191
+ get onUnmount() {
9192
+ return undefined;
9193
+ }
9194
+ /**
9195
+ * @inheritdoc
9196
+ */
9197
+ get onUnmounted() {
9198
+ return undefined;
9199
+ }
9200
+ /**
9201
+ * @inheritdoc
9202
+ */
9203
+ destroy() {
9204
+ if (this.#destroyed) {
9205
+ return;
9206
+ }
9207
+ this.#destroyed = true;
9208
+ // Destroy the content VNode so its dependency registrations on the
9209
+ // host VNode are released (critical for v-for rows that come and go).
9210
+ // The content DOM nodes live inside the component's subtree and are
9211
+ // removed together with it.
9212
+ this.#contentVNode?.destroy();
9213
+ this.#contentVNode = undefined;
9214
+ this.#scopeBindings = undefined;
9215
+ }
9216
+ /**
9217
+ * Renders the scoped slot content in place of the `<slot>` element.
9218
+ */
9219
+ #render() {
9220
+ const slot = this.#vNode.node;
9221
+ // Scope: parent application's bindings + the scope variable
9222
+ this.#scopeBindings = new VBindings({ parent: this.#hostVNode.bindings });
9223
+ if (this.#entry.scopeVar) {
9224
+ this.#scopeBindings.setLocal(this.#entry.scopeVar, this.#buildSlotProps());
9225
+ }
9226
+ // Clone the scoped template's content and create its VNode tree BEFORE
9227
+ // inserting into the DOM, so components inside the slot content are
9228
+ // compiled before their connectedCallback can expand them.
9229
+ // (Created through the parent application to keep the VNode import
9230
+ // type-only; a direct `new VNode` here would close a module cycle
9231
+ // VNode -> VDirectiveManager -> VSlotOutletDirective -> VNode.)
9232
+ const fragment = this.#entry.template.content.cloneNode(true);
9233
+ this.#contentVNode = this.#hostVNode.vApplication.createVNode({
9234
+ node: fragment,
9235
+ vApplication: this.#hostVNode.vApplication,
9236
+ parentVNode: this.#hostVNode,
9237
+ bindings: this.#scopeBindings
9238
+ });
9239
+ // Drop the fallback children (a scoped template supersedes them), then
9240
+ // swap the <slot> element for the content.
9241
+ while (slot.firstChild) {
9242
+ slot.removeChild(slot.firstChild);
9243
+ }
9244
+ slot.replaceWith(fragment);
9245
+ // Initial rendering
9246
+ this.#contentVNode.forceUpdate();
9247
+ }
9248
+ /**
9249
+ * Re-evaluates the slot props (component-side change) and pushes them into
9250
+ * the content scope.
9251
+ */
9252
+ #refresh() {
9253
+ if (!this.#contentVNode || !this.#scopeBindings || !this.#entry.scopeVar) {
9254
+ return;
9255
+ }
9256
+ this.#scopeBindings.setLocal(this.#entry.scopeVar, this.#buildSlotProps());
9257
+ this.#contentVNode.forceUpdate();
9258
+ }
9259
+ /**
9260
+ * Builds the slot props object from the outlet's `v-bind` attributes,
9261
+ * evaluated in the component scope.
9262
+ */
9263
+ #buildSlotProps() {
9264
+ const props = {};
9265
+ for (const prop of this.#props) {
9266
+ props[prop.name] = prop.evaluator?.evaluate();
9267
+ }
9268
+ return props;
9269
+ }
9270
+ #kebabToCamel(str) {
9271
+ return str.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
9272
+ }
9273
+ }
9274
+
8929
9275
  // Copyright (c) 2025 MintJams Inc. Licensed under MIT License.
8930
9276
  /**
8931
9277
  * Manages directives associated with a virtual node (VNode).
@@ -9092,6 +9438,24 @@
9092
9438
  }
9093
9439
  #parseDirectives() {
9094
9440
  const element = this.#vNode.node;
9441
+ // Parse directives from attributes
9442
+ const directives = [];
9443
+ // Scoped slot outlet: a <slot> element (without structural directives)
9444
+ // inside a component whose host received a matching <template v-slot:...>.
9445
+ // This is an element-based directive, created before the generic
9446
+ // attribute parsing so it can consume the slot-prop bindings (:x).
9447
+ // A <slot> carrying v-if / v-for is handled structurally first; the
9448
+ // outlet is then created when the clone is compiled.
9449
+ if (element.tagName === 'SLOT' &&
9450
+ !element.hasAttribute(StandardDirectiveName.V_FOR) &&
9451
+ !element.hasAttribute(StandardDirectiveName.V_IF) &&
9452
+ !element.hasAttribute(StandardDirectiveName.V_ELSE_IF) &&
9453
+ !element.hasAttribute(StandardDirectiveName.V_ELSE)) {
9454
+ const outlet = VSlotOutletDirective.tryCreate(this.#vNode);
9455
+ if (outlet) {
9456
+ directives.push(outlet);
9457
+ }
9458
+ }
9095
9459
  // Collect relevant attributes
9096
9460
  // For v-for, v-if, v-else-if, v-else: process only the structural directive on the template element.
9097
9461
  // Other directives (@click, :class, etc.) will be processed on the cloned/rendered elements.
@@ -9127,8 +9491,6 @@
9127
9491
  else {
9128
9492
  attributes.push(...Array.from(element.attributes));
9129
9493
  }
9130
- // Parse directives from attributes
9131
- const directives = [];
9132
9494
  for (const attribute of attributes) {
9133
9495
  // Create a context for parsing the directive
9134
9496
  const context = {
@@ -9176,6 +9538,83 @@
9176
9538
  }
9177
9539
  }
9178
9540
 
9541
+ // Copyright (c) 2025 MintJams Inc. Licensed under MIT License.
9542
+ /**
9543
+ * The default Object.prototype.toString, used to detect plain objects that
9544
+ * have not overridden their string representation.
9545
+ */
9546
+ const objectToString = Object.prototype.toString;
9547
+ /**
9548
+ * Checks if the value is a non-null object.
9549
+ */
9550
+ function isObject(value) {
9551
+ return value !== null && typeof value === 'object';
9552
+ }
9553
+ /**
9554
+ * Checks if the value is a plain object (i.e. `[object Object]`).
9555
+ */
9556
+ function isPlainObject(value) {
9557
+ return objectToString.call(value) === '[object Object]';
9558
+ }
9559
+ /**
9560
+ * Converts a symbol to a readable string, leaving other values untouched.
9561
+ * Used when rendering Map keys and Set entries.
9562
+ */
9563
+ function stringifySymbol(value, index = '') {
9564
+ return typeof value === 'symbol' ? `Symbol(${value.description ?? index})` : value;
9565
+ }
9566
+ /**
9567
+ * JSON.stringify replacer that renders values JSON cannot represent natively
9568
+ * (Map, Set, Symbol, and non-plain objects) in a readable form.
9569
+ */
9570
+ function replacer(_key, value) {
9571
+ if (value instanceof Map) {
9572
+ return {
9573
+ [`Map(${value.size})`]: [...value.entries()].reduce((entries, [key, val], i) => {
9574
+ entries[`${stringifySymbol(key, i)} =>`] = val;
9575
+ return entries;
9576
+ }, {}),
9577
+ };
9578
+ }
9579
+ if (value instanceof Set) {
9580
+ return {
9581
+ [`Set(${value.size})`]: [...value.values()].map(v => stringifySymbol(v)),
9582
+ };
9583
+ }
9584
+ if (typeof value === 'symbol') {
9585
+ return stringifySymbol(value);
9586
+ }
9587
+ if (isObject(value) && !Array.isArray(value) && !isPlainObject(value)) {
9588
+ // Non-plain objects (Date, RegExp, DOM nodes, ...) render via their own toString
9589
+ return String(value);
9590
+ }
9591
+ return value;
9592
+ }
9593
+ /**
9594
+ * Converts a value to a string suitable for display in rendered output,
9595
+ * following Vue's `toDisplayString` semantics:
9596
+ * - strings are returned as-is
9597
+ * - null and undefined become an empty string
9598
+ * - arrays and plain objects (without a custom toString) are rendered as
9599
+ * pretty-printed JSON, with Map/Set/Symbol entries made readable
9600
+ * - everything else is converted with String()
9601
+ * @param value The value to convert.
9602
+ * @returns The display string for the value.
9603
+ */
9604
+ function toDisplayString(value) {
9605
+ if (typeof value === 'string') {
9606
+ return value;
9607
+ }
9608
+ if (value === null || value === undefined) {
9609
+ return '';
9610
+ }
9611
+ if (Array.isArray(value) ||
9612
+ (isObject(value) && (value.toString === objectToString || typeof value.toString !== 'function'))) {
9613
+ return JSON.stringify(value, replacer, 2);
9614
+ }
9615
+ return String(value);
9616
+ }
9617
+
9179
9618
  // Copyright (c) 2025 MintJams Inc. Licensed under MIT License.
9180
9619
  /**
9181
9620
  * List of standard JavaScript global objects that should be available in expressions.
@@ -9296,8 +9735,9 @@
9296
9735
  return GLOBAL_OBJECTS[name];
9297
9736
  return undefined;
9298
9737
  });
9299
- // Evaluate the expression and replace {{...}} in the text
9300
- result = result.replace(matches[i][0], String(evaluator.func(...values)));
9738
+ // Evaluate the expression and replace {{...}} in the text.
9739
+ // A function replacement keeps `$`-patterns in the value literal.
9740
+ result = result.replace(matches[i][0], () => toDisplayString(evaluator.func(...values)));
9301
9741
  });
9302
9742
  return result;
9303
9743
  };
@@ -9443,7 +9883,14 @@
9443
9883
  }
9444
9884
  else if (this.#nodeType === Node.ELEMENT_NODE) {
9445
9885
  // If the node is an element, initialize directives and child nodes
9446
- this.#node;
9886
+ const element = this.#node;
9887
+ // If the element is a component defined via defineComponent(), install
9888
+ // the host context so component-boundary features (e.g. scoped slot
9889
+ // outlets) can reach back into this application's scope.
9890
+ const isComponent = IchigoElementRegistry.has(this.#nodeName);
9891
+ if (isComponent) {
9892
+ element._ichigoHost = { vNode: this };
9893
+ }
9447
9894
  // Initialize child virtual nodes
9448
9895
  this.#childVNodes = [];
9449
9896
  // Initialize directive manager
@@ -9456,8 +9903,19 @@
9456
9903
  d.onMount?.();
9457
9904
  });
9458
9905
  }
9906
+ // A component whose template is already expanded compiles its own
9907
+ // internals with its own VApplication — this (parent) application must
9908
+ // not adopt them (that would strip the component's directives and
9909
+ // evaluate its expressions in the wrong scope). An unexpanded component
9910
+ // element only contains authored slot content, which does belong to
9911
+ // this application's scope and is compiled normally.
9912
+ const isExpandedComponent = isComponent && element._ichigoExpanded === true;
9913
+ if (isExpandedComponent) {
9914
+ this.#vApplication.logManager.getLogger(this.constructor.name)
9915
+ .debug(`Skipped compiling internals of expanded component <${this.#nodeName.toLowerCase()}>`);
9916
+ }
9459
9917
  // Create child virtual nodes if template preservation is not required
9460
- if (!this.#templatized) {
9918
+ if (!this.#templatized && !isExpandedComponent) {
9461
9919
  for (const childNode of Array.from(this.#node.childNodes)) {
9462
9920
  new VNode({
9463
9921
  node: childNode,
@@ -11495,17 +11953,45 @@
11495
11953
  * The modifiers for this v-model directive (e.g., "lazy", "number", "trim")
11496
11954
  */
11497
11955
  #modifiers = new Set();
11956
+ /**
11957
+ * The argument of the directive (e.g. "size" for `v-model:size`), camelized.
11958
+ * Only meaningful on components; selects the target prop and update event
11959
+ * (prop `size` + event `update:size`). Undefined = the default `modelValue`.
11960
+ */
11961
+ #arg;
11962
+ /**
11963
+ * Ensures the "v-model targets an undeclared prop" warning fires only once.
11964
+ */
11965
+ #warnedUndeclaredProp = false;
11498
11966
  /**
11499
11967
  * @param context The context for parsing the directive.
11500
11968
  */
11501
11969
  constructor(context) {
11502
11970
  this.#vNode = context.vNode;
11503
- // Extract modifiers from the directive name
11504
- // e.g., "v-model.lazy.trim" -> modifiers = ["lazy", "trim"]
11971
+ // Extract the optional argument and modifiers from the directive name
11972
+ // e.g. "v-model" / "v-model.lazy.trim" / "v-model:size" / "v-model:size.number"
11505
11973
  const attrName = context.attribute.name;
11506
- if (attrName.startsWith('v-model.')) {
11507
- const parts = attrName.split('.');
11508
- parts.slice(1).forEach(mod => this.#modifiers.add(mod));
11974
+ let rest = attrName.substring(StandardDirectiveName.V_MODEL.length);
11975
+ if (rest.startsWith(':')) {
11976
+ const dotIndex = rest.indexOf('.');
11977
+ const rawArg = dotIndex === -1 ? rest.substring(1) : rest.substring(1, dotIndex);
11978
+ if (rawArg) {
11979
+ // HTML attribute names are lowercased by the browser, so a
11980
+ // camelCase prop is addressed with kebab-case (v-model:inner-title).
11981
+ this.#arg = rawArg.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
11982
+ }
11983
+ rest = dotIndex === -1 ? '' : rest.substring(dotIndex);
11984
+ }
11985
+ if (rest.startsWith('.')) {
11986
+ rest.split('.').slice(1).forEach(mod => this.#modifiers.add(mod));
11987
+ }
11988
+ const element = context.vNode.node;
11989
+ if (this.#arg && !this.#isComponentElement(element)) {
11990
+ console.warn(`[ichigo] v-model:${this.#arg} is only supported on components; the argument is ignored on <${element.tagName.toLowerCase()}>`);
11991
+ this.#arg = undefined;
11992
+ }
11993
+ if (this.#isComponentElement(element) && this.#modifiers.has('lazy')) {
11994
+ console.warn(`[ichigo] <${element.tagName.toLowerCase()}>: the .lazy modifier has no effect on components (the component decides when to emit 'update:${this.#propName}')`);
11509
11995
  }
11510
11996
  // Parse the expression and create the evaluator
11511
11997
  const expression = context.attribute.value;
@@ -11677,6 +12163,14 @@
11677
12163
  }
11678
12164
  // Evaluate the expression to get the value
11679
12165
  const value = this.#evaluator.evaluate();
12166
+ // Components: deliver the value as a property (prop `modelValue`, or the
12167
+ // directive argument). For ichigo components this reaches the reactive
12168
+ // bindings through the generated prop setter.
12169
+ if (this.#isComponentElement(element)) {
12170
+ const propName = this.#resolveComponentPropName(element);
12171
+ element[propName] = value;
12172
+ return;
12173
+ }
11680
12174
  // Update the element based on its type
11681
12175
  if (element instanceof HTMLInputElement) {
11682
12176
  if (element.type === 'checkbox') {
@@ -11710,6 +12204,19 @@
11710
12204
  const element = this.#vNode.node;
11711
12205
  const eventName = this.#getEventName();
11712
12206
  this.#listener = (event) => {
12207
+ // Components: the payload of the `update:<prop>` CustomEvent is the
12208
+ // new value. Only events emitted by this component itself are
12209
+ // accepted — the default $emit fires on the component's root element
12210
+ // (a direct child of the host), so an event whose target lies deeper
12211
+ // was emitted by a nested component and bubbled through.
12212
+ if (this.#isComponentElement(element)) {
12213
+ const src = event.target;
12214
+ if (src !== element && src?.parentElement !== element) {
12215
+ return;
12216
+ }
12217
+ this.#updateBinding(this.#applyModifiers(event.detail));
12218
+ return;
12219
+ }
11713
12220
  const target = event.target;
11714
12221
  let newValue;
11715
12222
  // Get the new value based on element type
@@ -11864,11 +12371,52 @@
11864
12371
  }
11865
12372
  return result;
11866
12373
  }
12374
+ /**
12375
+ * The target prop name on a component: the camelized directive argument,
12376
+ * or the `modelValue` convention when no argument is given.
12377
+ */
12378
+ get #propName() {
12379
+ return this.#arg ?? 'modelValue';
12380
+ }
12381
+ /**
12382
+ * Returns true when the bound element is a component (custom element).
12383
+ * The `modelValue` / `update:modelValue` contract works for any custom
12384
+ * element that follows it, not only ichigo components.
12385
+ */
12386
+ #isComponentElement(element) {
12387
+ return element.tagName.includes('-');
12388
+ }
12389
+ /**
12390
+ * Resolves the property name to assign on a component. For ichigo
12391
+ * components the declared prop list fixes up case-insensitive mismatches
12392
+ * (HTML-lowercased arguments vs. camelCase props) and an undeclared target
12393
+ * prop logs a one-time development warning.
12394
+ */
12395
+ #resolveComponentPropName(element) {
12396
+ const name = this.#propName;
12397
+ const props = element.constructor._props;
12398
+ if (Array.isArray(props)) {
12399
+ const lower = name.toLowerCase();
12400
+ const canonical = props.find(p => p.toLowerCase() === lower);
12401
+ if (canonical) {
12402
+ return canonical;
12403
+ }
12404
+ if (!this.#warnedUndeclaredProp) {
12405
+ this.#warnedUndeclaredProp = true;
12406
+ console.warn(`[ichigo] <${element.tagName.toLowerCase()}>: v-model targets prop '${name}', which is not declared in the component's props`);
12407
+ }
12408
+ }
12409
+ return name;
12410
+ }
11867
12411
  /**
11868
12412
  * Gets the appropriate event name for the element type.
11869
12413
  */
11870
12414
  #getEventName() {
11871
12415
  const element = this.#vNode.node;
12416
+ // Components: listen for the `update:<prop>` convention event.
12417
+ if (this.#isComponentElement(element)) {
12418
+ return 'update:' + this.#propName;
12419
+ }
11872
12420
  // .lazy modifier: use 'change' event instead of 'input'
11873
12421
  if (this.#modifiers.has('lazy')) {
11874
12422
  return 'change';
@@ -13058,8 +13606,7 @@
13058
13606
  },
13059
13607
  applyToDOM() {
13060
13608
  const element = vNode.node;
13061
- const textContent = evaluator.evaluate();
13062
- element.textContent = textContent ?? '';
13609
+ element.textContent = toDisplayString(evaluator.evaluate());
13063
13610
  }
13064
13611
  };
13065
13612
  return updater;
@@ -13609,9 +14156,10 @@
13609
14156
  // v-bind:<attribute>, :<attribute>
13610
14157
  context.attribute.name.startsWith(StandardDirectiveName.V_BIND + ":") ||
13611
14158
  context.attribute.name.startsWith(":") ||
13612
- // v-model, v-model.<modifier>
14159
+ // v-model, v-model.<modifier>, v-model:<arg>, v-model:<arg>.<modifier>
13613
14160
  context.attribute.name === StandardDirectiveName.V_MODEL ||
13614
14161
  context.attribute.name.startsWith(StandardDirectiveName.V_MODEL + ".") ||
14162
+ context.attribute.name.startsWith(StandardDirectiveName.V_MODEL + ":") ||
13615
14163
  // v-resize
13616
14164
  context.attribute.name === StandardDirectiveName.V_RESIZE ||
13617
14165
  // v-intersection
@@ -13679,9 +14227,10 @@
13679
14227
  context.attribute.name.startsWith(":")) {
13680
14228
  return new VBindDirective(context);
13681
14229
  }
13682
- // v-model, v-model.<modifier>
14230
+ // v-model, v-model.<modifier>, v-model:<arg>, v-model:<arg>.<modifier>
13683
14231
  if (context.attribute.name === StandardDirectiveName.V_MODEL ||
13684
- context.attribute.name.startsWith(StandardDirectiveName.V_MODEL + ".")) {
14232
+ context.attribute.name.startsWith(StandardDirectiveName.V_MODEL + ".") ||
14233
+ context.attribute.name.startsWith(StandardDirectiveName.V_MODEL + ":")) {
13685
14234
  return new VModelDirective(context);
13686
14235
  }
13687
14236
  // v-resize
@@ -14105,6 +14654,14 @@
14105
14654
  * Flag to indicate if an update is already scheduled.
14106
14655
  */
14107
14656
  #updateScheduled = false;
14657
+ /**
14658
+ * The custom element hosting this application when it powers a component
14659
+ * defined via defineComponent(). Set by IchigoElement before mounting.
14660
+ * Used by component-boundary features (e.g. scoped slot outlets) to reach
14661
+ * the host's captured slot templates and parent application scope.
14662
+ * Undefined for regular (non-component) applications.
14663
+ */
14664
+ #hostElement;
14108
14665
  /**
14109
14666
  * Creates an instance of the virtual application.
14110
14667
  * @param args The initialization arguments for the application.
@@ -14208,9 +14765,32 @@
14208
14765
  get refs() {
14209
14766
  return this.#refs;
14210
14767
  }
14768
+ /**
14769
+ * The custom element hosting this application when it powers a component
14770
+ * (see the #hostElement field). Undefined for regular applications.
14771
+ */
14772
+ get hostElement() {
14773
+ return this.#hostElement;
14774
+ }
14775
+ set hostElement(element) {
14776
+ this.#hostElement = element;
14777
+ }
14778
+ /**
14779
+ * Creates a VNode. Directives that need to build VNode trees at runtime
14780
+ * (e.g. the scoped slot outlet) call this instead of constructing VNode
14781
+ * directly, so they can keep their VNode import type-only and avoid a
14782
+ * VNode -> VDirectiveManager -> directive -> VNode module cycle.
14783
+ * @param init The initialization arguments for the virtual node.
14784
+ * @returns The created VNode.
14785
+ */
14786
+ createVNode(init) {
14787
+ return new VNode(init);
14788
+ }
14211
14789
  /**
14212
14790
  * Mounts the application.
14213
14791
  * @param target The CSS selector string or HTMLElement to mount the application to.
14792
+ * @returns This application instance, so `VDOM.createApp(...).mount('#app')`
14793
+ * can be assigned for later access (e.g. `app.bindings`, `app.unmount()`).
14214
14794
  */
14215
14795
  mount(target) {
14216
14796
  let element;
@@ -14241,6 +14821,7 @@
14241
14821
  });
14242
14822
  element.removeAttribute('v-cloak');
14243
14823
  this.#logger.info('Application mounted.');
14824
+ return this;
14244
14825
  }
14245
14826
  /**
14246
14827
  * Unmounts the application and cleans up resources.
@@ -14291,7 +14872,15 @@
14291
14872
  else {
14292
14873
  buffer = null;
14293
14874
  if (node.nodeType === Node.ELEMENT_NODE) {
14294
- this.#cleanElement(node);
14875
+ const child = node;
14876
+ // Do not clean inside an expanded component: its DOM belongs
14877
+ // to the component's own application, and removing rendered
14878
+ // (possibly empty) text nodes would detach them from that
14879
+ // application's virtual nodes, freezing its output.
14880
+ if (IchigoElementRegistry.has(child.tagName) && child._ichigoExpanded === true) {
14881
+ continue;
14882
+ }
14883
+ this.#cleanElement(child);
14295
14884
  }
14296
14885
  }
14297
14886
  }
@@ -14687,7 +15276,8 @@
14687
15276
  */
14688
15277
  #emit(name, detail, options) {
14689
15278
  // Documentation/validation only: warn when emitting an event not declared in `emits`.
14690
- if (this.#options.emits && !this.#options.emits.includes(name)) {
15279
+ // `update:*` events are the component v-model convention and are always allowed.
15280
+ if (this.#options.emits && !name.startsWith('update:') && !this.#options.emits.includes(name)) {
14691
15281
  this.#logger.warn(`Event '${name}' is emitted but not declared in the 'emits' option.`);
14692
15282
  }
14693
15283
  const target = options?.target ?? this.#vNode?.node;
@@ -14790,13 +15380,19 @@
14790
15380
  * Mount timing:
14791
15381
  * - If the component declares no props, the VApplication is mounted synchronously
14792
15382
  * at the end of connectedCallback (the template is known, no props to wait for).
14793
- * - If the component declares props, the VApplication is mounted the first time
14794
- * _setProp() is called after connectedCallback has prepared the DOM. This
14795
- * guarantees that the parent framework (e.g. ichigo.js VBindDirective) has
14796
- * already delivered at least one prop value before data() is evaluated.
15383
+ * - If the component declares props, a mount microtask is scheduled at the end of
15384
+ * connectedCallback. Prop values delivered synchronously by the parent framework
15385
+ * (VBindDirective runs within the parent's mount task) are stored before the
15386
+ * microtask fires, so data() can read them. A component used without any prop
15387
+ * binding still mounts — with its declared defaults — instead of waiting forever.
15388
+ *
15389
+ * Slot content is captured on the first connection and cached, so a component
15390
+ * that is moved in the DOM (e.g. reordered by v-for) re-expands its template
15391
+ * and redistributes the same slot nodes on reconnection.
14797
15392
  *
14798
- * Subclasses must set the static fields _template, _props and _buildOptions before
14799
- * calling customElements.define(). defineComponent() handles this automatically.
15393
+ * Subclasses must set the static fields _template, _props, _propOptions and
15394
+ * _buildOptions before calling customElements.define(). defineComponent()
15395
+ * handles this automatically.
14800
15396
  */
14801
15397
  class IchigoElement extends HTMLElement {
14802
15398
  /**
@@ -14816,43 +15412,118 @@
14816
15412
  * Whether a mount microtask is already queued to avoid double-mounting.
14817
15413
  */
14818
15414
  #mountScheduled = false;
15415
+ /**
15416
+ * Slot content captured on the first connection. Reused on reconnection
15417
+ * (v-for reorder moves the host element, which re-runs connectedCallback
15418
+ * with the previously expanded template as children — not authored slot
15419
+ * content — so the original capture must be redistributed instead).
15420
+ * `scopedSlots` holds `<template v-slot:...>` children by slot name; they
15421
+ * are rendered per outlet by VSlotOutletDirective, not distributed here.
15422
+ */
15423
+ #slotCache;
15424
+ /**
15425
+ * True once this element's children are the expanded component template.
15426
+ * Read by VNode during compilation: a parent application must not compile
15427
+ * an expanded component's internals as its own template (the internals
15428
+ * belong to this component's VApplication and scope).
15429
+ */
15430
+ _ichigoExpanded = false;
15431
+ /**
15432
+ * Host context installed by the parent application's VNode when it
15433
+ * compiles this element. Gives component-boundary features (e.g. scoped
15434
+ * slot outlets) access to the parent scope. Undefined when the element is
15435
+ * placed outside any ichigo application.
15436
+ */
15437
+ _ichigoHost;
15438
+ /**
15439
+ * The scoped slot templates captured from this element's children, keyed
15440
+ * by slot name ('default' for `v-slot` without a name). Read by
15441
+ * VSlotOutletDirective when the component template renders a `<slot>`.
15442
+ */
15443
+ get _ichigoScopedSlots() {
15444
+ return this.#slotCache?.scopedSlots;
15445
+ }
14819
15446
  connectedCallback() {
14820
- // --- 0. Guard: skip if template not yet loaded ---
15447
+ // --- 0a. Guard: do not expand a template-source element ---
15448
+ // An element still carrying a templatizing directive attribute (v-if
15449
+ // family / v-for) is a template source: the owning application clones it
15450
+ // and inserts the clones (whose directive attributes are stripped during
15451
+ // clone compilation, so the clones expand normally). Expanding the source
15452
+ // here would bake the expansion into every clone, and the clone's
15453
+ // internals would then be compiled in the parent scope.
15454
+ if (this.hasAttribute(StandardDirectiveName.V_IF) ||
15455
+ this.hasAttribute(StandardDirectiveName.V_ELSE_IF) ||
15456
+ this.hasAttribute(StandardDirectiveName.V_ELSE) ||
15457
+ this.hasAttribute(StandardDirectiveName.V_FOR)) {
15458
+ return;
15459
+ }
15460
+ // --- 0b. Reclaim prop values assigned before upgrade ---
15461
+ // A plain property set on the instance (e.g. by a binding delivered
15462
+ // while the element was detached or not yet upgraded) is an own data
15463
+ // property that shadows the prototype accessor generated by
15464
+ // defineComponent — later assignments would keep writing to it and
15465
+ // never reach _setProp. Delete the own property and replay the value
15466
+ // through the accessor.
15467
+ const ctor = this.constructor;
15468
+ for (const prop of ctor._props) {
15469
+ if (Object.prototype.hasOwnProperty.call(this, prop)) {
15470
+ const value = this[prop];
15471
+ delete this[prop];
15472
+ this[prop] = value;
15473
+ }
15474
+ }
15475
+ // --- 0c. Guard: skip if template not yet loaded ---
14821
15476
  // customElements.define() may run before loadComponent() completes (e.g. when
14822
15477
  // dynamic imports are inlined by the bundler). The static placeholder element
14823
15478
  // in the HTML will be removed and replaced by v-for once the template is ready,
14824
15479
  // so it is safe to do nothing here.
14825
- const ctor = this.constructor;
14826
15480
  const templateEl = document.querySelector(ctor._template);
14827
15481
  if (!templateEl || !(templateEl instanceof HTMLTemplateElement)) {
14828
15482
  return;
14829
15483
  }
14830
- // --- 1. Capture slot content before clearing children ---
14831
- const defaultSlotNodes = [];
14832
- const namedSlotNodes = new Map();
14833
- for (const child of Array.from(this.childNodes)) {
14834
- if (child.nodeType === Node.ELEMENT_NODE) {
14835
- const el = child;
14836
- const slotName = el.getAttribute('slot');
14837
- if (slotName) {
14838
- if (!namedSlotNodes.has(slotName)) {
14839
- namedSlotNodes.set(slotName, []);
15484
+ // --- 1. Capture slot content (first connection only) ---
15485
+ if (!this.#slotCache) {
15486
+ const defaultNodes = [];
15487
+ const namedNodes = new Map();
15488
+ const scopedSlots = new Map();
15489
+ for (const child of Array.from(this.childNodes)) {
15490
+ if (child.nodeType === Node.ELEMENT_NODE) {
15491
+ const el = child;
15492
+ // <template v-slot:name="scopeVar"> — a scoped slot template.
15493
+ // Captured for per-outlet rendering (VSlotOutletDirective),
15494
+ // not distributed statically.
15495
+ if (el instanceof HTMLTemplateElement) {
15496
+ const scoped = this.#parseVSlotAttribute(el);
15497
+ if (scoped) {
15498
+ scopedSlots.set(scoped.name, { template: el, scopeVar: scoped.scopeVar });
15499
+ continue;
15500
+ }
15501
+ }
15502
+ const slotName = el.getAttribute('slot');
15503
+ if (slotName) {
15504
+ if (!namedNodes.has(slotName)) {
15505
+ namedNodes.set(slotName, []);
15506
+ }
15507
+ namedNodes.get(slotName).push(el);
15508
+ // Remove slot attribute so ichigo.js doesn't try to bind it
15509
+ el.removeAttribute('slot');
15510
+ }
15511
+ else {
15512
+ defaultNodes.push(el);
14840
15513
  }
14841
- namedSlotNodes.get(slotName).push(el);
14842
- // Remove slot attribute so ichigo.js doesn't try to bind it
14843
- el.removeAttribute('slot');
14844
- }
14845
- else {
14846
- defaultSlotNodes.push(el);
14847
15514
  }
14848
- }
14849
- else if (child.nodeType === Node.TEXT_NODE) {
14850
- if ((child.textContent ?? '').trim()) {
14851
- defaultSlotNodes.push(child);
15515
+ else if (child.nodeType === Node.TEXT_NODE) {
15516
+ if ((child.textContent ?? '').trim()) {
15517
+ defaultNodes.push(child);
15518
+ }
14852
15519
  }
14853
15520
  }
15521
+ this.#slotCache = { defaultNodes, namedNodes, scopedSlots };
14854
15522
  }
14855
- // Clear host element so we can append the cloned template
15523
+ const { defaultNodes, namedNodes } = this.#slotCache;
15524
+ // Clear host element so we can append the cloned template.
15525
+ // (On reconnection this discards the previous expansion; the cached slot
15526
+ // nodes are still referenced and are moved into the fresh clone below.)
14856
15527
  while (this.firstChild) {
14857
15528
  this.removeChild(this.firstChild);
14858
15529
  }
@@ -14860,7 +15531,7 @@
14860
15531
  const fragment = templateEl.content.cloneNode(true);
14861
15532
  const root = this.#findRootElement(fragment);
14862
15533
  // --- 3. Distribute named slot content ---
14863
- for (const [name, nodes] of namedSlotNodes) {
15534
+ for (const [name, nodes] of namedNodes) {
14864
15535
  const slot = root.querySelector(`slot[name="${name}"]`);
14865
15536
  if (slot) {
14866
15537
  slot.replaceWith(...nodes);
@@ -14868,22 +15539,25 @@
14868
15539
  }
14869
15540
  // --- 4. Distribute default slot content ---
14870
15541
  const defaultSlot = root.querySelector('slot:not([name])');
14871
- if (defaultSlot && defaultSlotNodes.length > 0) {
14872
- defaultSlot.replaceWith(...defaultSlotNodes);
15542
+ if (defaultSlot && defaultNodes.length > 0) {
15543
+ defaultSlot.replaceWith(...defaultNodes);
14873
15544
  }
14874
15545
  // Attach the populated template to the host element
14875
15546
  this.appendChild(root);
14876
15547
  this.#mountRoot = root;
14877
- // If props were set before connectedCallback, ensure mount is scheduled
14878
- this.#scheduleMountIfNeeded();
15548
+ this._ichigoExpanded = true;
14879
15549
  // --- 5. Mount strategy ---
14880
- // If this component has no declared props, mount immediately.
14881
- // If it has props, mount will be triggered from _setProp() once the parent
14882
- // delivers the first prop value (via VBindDirective / forceUpdate).
15550
+ // No declared props: mount immediately (nothing to wait for).
15551
+ // Declared props: schedule a mount microtask unconditionally. Prop values
15552
+ // pushed synchronously by the parent (VBindDirective) land before the
15553
+ // microtask runs; a component used without any binding still mounts,
15554
+ // with its declared defaults.
14883
15555
  if (ctor._props.length === 0) {
14884
15556
  this.#doMount();
14885
15557
  }
14886
- // else: wait for _setProp() to trigger #scheduleMountIfNeeded()
15558
+ else {
15559
+ this.#scheduleMountIfNeeded();
15560
+ }
14887
15561
  }
14888
15562
  disconnectedCallback() {
14889
15563
  this.#mountRoot = undefined;
@@ -14897,8 +15571,17 @@
14897
15571
  * Called by the property setters generated by defineComponent().
14898
15572
  * Before mount: stores the value and schedules a mount microtask.
14899
15573
  * After mount: pushes the value directly into the reactive bindings.
15574
+ * An undefined value is replaced by the prop's declared default, if any.
14900
15575
  */
14901
15576
  _setProp(name, value) {
15577
+ const ctor = this.constructor;
15578
+ const opts = ctor._propOptions[name];
15579
+ if (opts) {
15580
+ if (value === undefined) {
15581
+ value = IchigoElement.#resolveDefault(opts);
15582
+ }
15583
+ this.#validateProp(name, value, opts);
15584
+ }
14902
15585
  this.#propValues[name] = value;
14903
15586
  if (this.#app) {
14904
15587
  this.#app.bindings?.set(name, value);
@@ -14919,9 +15602,16 @@
14919
15602
  */
14920
15603
  static _template;
14921
15604
  /**
14922
- * List of declared prop names. Used to decide whether to defer mounting.
15605
+ * List of declared prop names. Used to decide whether to defer mounting,
15606
+ * and by VBindDirective to resolve kebab-case attributes to prop names.
14923
15607
  */
14924
15608
  static _props = [];
15609
+ /**
15610
+ * Per-prop declaration options (type / default / required / validator),
15611
+ * keyed by prop name. Normalized by defineComponent(): a prop declared
15612
+ * without options gets an empty object.
15613
+ */
15614
+ static _propOptions = {};
14925
15615
  /**
14926
15616
  * Factory that builds VApplicationOptions from the current prop values.
14927
15617
  * Implemented by defineComponent() as a closure that captures the user's options.
@@ -14930,7 +15620,8 @@
14930
15620
  // --- Private helpers ---
14931
15621
  /**
14932
15622
  * Schedules a mount microtask if the DOM root is ready and no mount is pending.
14933
- * Called from _setProp() so the mount happens after the prop value is stored.
15623
+ * Called from connectedCallback and _setProp() so the mount happens after
15624
+ * synchronously delivered prop values are stored.
14934
15625
  */
14935
15626
  #scheduleMountIfNeeded() {
14936
15627
  if (this.#mountScheduled || this.#app || !this.#mountRoot) {
@@ -14947,10 +15638,85 @@
14947
15638
  return;
14948
15639
  }
14949
15640
  const ctor = this.constructor;
15641
+ // Attribute fallback: a prop that never received a property value may
15642
+ // have been provided as a plain HTML attribute — static usage like
15643
+ // <my-badge label="x">, or a binding delivered by setAttribute before
15644
+ // the element was upgraded. Attribute values are strings; a declared
15645
+ // Boolean type turns presence ("") into true, and a declared Number
15646
+ // type parses numeric strings.
15647
+ for (const name of ctor._props) {
15648
+ if (this.#propValues[name] !== undefined) {
15649
+ continue;
15650
+ }
15651
+ const attr = this.getAttribute(IchigoElement.#camelToKebab(name)) ?? this.getAttribute(name.toLowerCase());
15652
+ if (attr === null) {
15653
+ continue;
15654
+ }
15655
+ const opts = ctor._propOptions[name];
15656
+ let value = attr;
15657
+ if (opts && IchigoElement.#typeIncludes(opts.type, Boolean) && attr === '') {
15658
+ value = true;
15659
+ }
15660
+ else if (opts && IchigoElement.#typeIncludes(opts.type, Number) && attr !== '' && !Number.isNaN(Number(attr))) {
15661
+ value = Number(attr);
15662
+ }
15663
+ if (opts) {
15664
+ this.#validateProp(name, value, opts);
15665
+ }
15666
+ this.#propValues[name] = value;
15667
+ }
15668
+ // Resolve defaults for props that never received a value, and report
15669
+ // missing required props (warning only; the mount proceeds either way).
15670
+ for (const [name, opts] of Object.entries(ctor._propOptions)) {
15671
+ if (this.#propValues[name] === undefined) {
15672
+ const resolved = IchigoElement.#resolveDefault(opts);
15673
+ if (resolved !== undefined) {
15674
+ this.#propValues[name] = resolved;
15675
+ }
15676
+ else if (opts.required) {
15677
+ this.#warn(`required prop '${name}' is missing`);
15678
+ }
15679
+ }
15680
+ }
14950
15681
  const options = ctor._buildOptions(this.#propValues);
14951
15682
  this.#app = VDOM.createApp(options);
15683
+ // Give the application a handle back to this host element so
15684
+ // component-boundary features (e.g. scoped slot outlets) can reach the
15685
+ // captured slot templates and the parent application scope.
15686
+ this.#app.hostElement = this;
14952
15687
  this.#app.mount(this.#mountRoot);
14953
15688
  }
15689
+ /**
15690
+ * Parses a `v-slot` declaration on a `<template>` child, accepting
15691
+ * `v-slot` / `v-slot:name` and the `#name` shorthand. Returns the slot
15692
+ * name ('default' when omitted) and the scope variable name, or undefined
15693
+ * when the template carries no v-slot attribute. The scope variable must
15694
+ * be a single identifier; an invalid one is ignored with a warning.
15695
+ */
15696
+ #parseVSlotAttribute(template) {
15697
+ for (const attr of Array.from(template.attributes)) {
15698
+ let name;
15699
+ if (attr.name === 'v-slot') {
15700
+ name = 'default';
15701
+ }
15702
+ else if (attr.name.startsWith('v-slot:')) {
15703
+ name = attr.name.substring(7);
15704
+ }
15705
+ else if (attr.name.startsWith('#')) {
15706
+ name = attr.name.substring(1);
15707
+ }
15708
+ if (name === undefined || name === '') {
15709
+ continue;
15710
+ }
15711
+ let scopeVar = attr.value.trim();
15712
+ if (scopeVar && !/^[A-Za-z_$][\w$]*$/.test(scopeVar)) {
15713
+ this.#warn(`v-slot scope must be a single identifier; '${scopeVar}' is ignored (destructuring is not supported)`);
15714
+ scopeVar = '';
15715
+ }
15716
+ return { name, scopeVar };
15717
+ }
15718
+ return undefined;
15719
+ }
14954
15720
  #findRootElement(fragment) {
14955
15721
  for (const node of Array.from(fragment.childNodes)) {
14956
15722
  if (node.nodeType === Node.ELEMENT_NODE) {
@@ -14959,6 +15725,74 @@
14959
15725
  }
14960
15726
  throw new Error(`IchigoElement: no root element found in template '${this.constructor._template}'`);
14961
15727
  }
15728
+ /**
15729
+ * Resolves a prop's default value. A function default is called as a
15730
+ * factory unless the declared type includes Function, in which case the
15731
+ * function itself is the value.
15732
+ */
15733
+ static #resolveDefault(opts) {
15734
+ const def = opts.default;
15735
+ if (def === undefined) {
15736
+ return undefined;
15737
+ }
15738
+ if (typeof def === 'function' && !IchigoElement.#typeIncludes(opts.type, Function)) {
15739
+ return def();
15740
+ }
15741
+ return def;
15742
+ }
15743
+ static #typeIncludes(type, ctor) {
15744
+ if (!type) {
15745
+ return false;
15746
+ }
15747
+ return Array.isArray(type) ? type.includes(ctor) : type === ctor;
15748
+ }
15749
+ static #camelToKebab(str) {
15750
+ return str.replace(/([A-Z])/g, '-$1').toLowerCase();
15751
+ }
15752
+ /**
15753
+ * Validates a delivered prop value against its declaration.
15754
+ * Warnings only — the value is stored and delivered regardless.
15755
+ * null/undefined values skip both checks (undefined is handled by
15756
+ * default/required; null is an explicit "no value").
15757
+ */
15758
+ #validateProp(name, value, opts) {
15759
+ if (value === null || value === undefined) {
15760
+ return;
15761
+ }
15762
+ if (opts.type) {
15763
+ const types = Array.isArray(opts.type) ? opts.type : [opts.type];
15764
+ if (!types.some(t => IchigoElement.#matchesType(value, t))) {
15765
+ const expected = types.map(t => t.name ?? String(t)).join(' | ');
15766
+ const actual = typeof value === 'object'
15767
+ ? (value?.constructor?.name ?? 'object')
15768
+ : typeof value;
15769
+ this.#warn(`prop '${name}' expects ${expected}, got ${actual}`);
15770
+ }
15771
+ }
15772
+ if (opts.validator && !opts.validator(value)) {
15773
+ this.#warn(`prop '${name}' failed its validator`);
15774
+ }
15775
+ }
15776
+ static #matchesType(value, type) {
15777
+ switch (type) {
15778
+ case String: return typeof value === 'string';
15779
+ case Number: return typeof value === 'number';
15780
+ case Boolean: return typeof value === 'boolean';
15781
+ case Function: return typeof value === 'function';
15782
+ case Array: return Array.isArray(value);
15783
+ case Object: return typeof value === 'object' && value !== null && !Array.isArray(value);
15784
+ default:
15785
+ try {
15786
+ return value instanceof type;
15787
+ }
15788
+ catch {
15789
+ return false;
15790
+ }
15791
+ }
15792
+ }
15793
+ #warn(message) {
15794
+ console.warn(`[ichigo] <${this.tagName.toLowerCase()}>: ${message}`);
15795
+ }
14962
15796
  }
14963
15797
 
14964
15798
  // Copyright (c) 2025 MintJams Inc. Licensed under MIT License.
@@ -14979,9 +15813,8 @@
14979
15813
  * ```typescript
14980
15814
  * defineComponent('my-list', {
14981
15815
  * template: '#my-list',
14982
- * props: ['items'],
14983
- * data() {
14984
- * return { items: this.items ?? [] };
15816
+ * props: {
15817
+ * items: { type: Array, default: () => [] },
14985
15818
  * },
14986
15819
  * });
14987
15820
  * ```
@@ -14991,15 +15824,34 @@
14991
15824
  * </my-list>
14992
15825
  * ```
14993
15826
  *
15827
+ * Props may be declared as a simple name list (`props: ['items']`) or as a
15828
+ * record with per-prop options (type / default / required / validator).
15829
+ * See {@link PropOptions} for the declaration semantics.
15830
+ *
14994
15831
  * @param tagName Custom element tag name (must contain a hyphen, e.g. 'my-card').
14995
15832
  * @param options Component options including template selector and optional props.
14996
15833
  */
14997
15834
  function defineComponent(tagName, options) {
14998
15835
  const { props = [], template, data, computed, methods, watch, emits, logLevel } = options;
15836
+ // Normalize the props declaration: a plain name list becomes a record of
15837
+ // empty option objects so the rest of the code handles a single shape.
15838
+ const propOptions = {};
15839
+ if (Array.isArray(props)) {
15840
+ for (const name of props) {
15841
+ propOptions[name] = {};
15842
+ }
15843
+ }
15844
+ else {
15845
+ for (const [name, opts] of Object.entries(props)) {
15846
+ propOptions[name] = opts ?? {};
15847
+ }
15848
+ }
15849
+ const propNames = Object.keys(propOptions);
14999
15850
  // Build a subclass of IchigoElement specific to this component
15000
15851
  class ComponentElement extends IchigoElement {
15001
15852
  static _template = template;
15002
- static _props = props;
15853
+ static _props = propNames;
15854
+ static _propOptions = propOptions;
15003
15855
  static _buildOptions(propValues) {
15004
15856
  return {
15005
15857
  data() {
@@ -15024,7 +15876,7 @@
15024
15876
  }
15025
15877
  // Generate a property getter/setter for each declared prop.
15026
15878
  // This enables the parent VApplication to push updates via `element.propName = value`.
15027
- for (const prop of props) {
15879
+ for (const prop of propNames) {
15028
15880
  Object.defineProperty(ComponentElement.prototype, prop, {
15029
15881
  get() {
15030
15882
  return this._getProp(prop);
@@ -15037,6 +15889,8 @@
15037
15889
  });
15038
15890
  }
15039
15891
  customElements.define(tagName, ComponentElement);
15892
+ // Record the tag so VNode can recognize component boundaries during compilation.
15893
+ IchigoElementRegistry.add(tagName);
15040
15894
  }
15041
15895
 
15042
15896
  exports.ExpressionUtils = ExpressionUtils;
@@ -15046,6 +15900,7 @@
15046
15900
  exports.VComponentRegistry = VComponentRegistry;
15047
15901
  exports.VDOM = VDOM;
15048
15902
  exports.defineComponent = defineComponent;
15903
+ exports.toDisplayString = toDisplayString;
15049
15904
 
15050
15905
  }));
15051
15906
  //# sourceMappingURL=ichigo.cjs.map