@mintjamsinc/ichigojs 0.1.76 → 0.1.78

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.
@@ -7505,6 +7505,14 @@ class VBindDirective {
7505
7505
  * @inheritdoc
7506
7506
  */
7507
7507
  get onMount() {
7508
+ // A constant expression (e.g. :label="'Apple'") has no reactive
7509
+ // identifiers, so the identifier-driven update cycle never applies it.
7510
+ // Render it once at mount instead.
7511
+ if (this.#evaluator && this.#evaluator.dependentIdentifiers.length === 0) {
7512
+ return () => {
7513
+ this.#render();
7514
+ };
7515
+ }
7508
7516
  return undefined;
7509
7517
  }
7510
7518
  /**
@@ -8920,6 +8928,344 @@ class VBindings {
8920
8928
  }
8921
8929
  }
8922
8930
 
8931
+ // Copyright (c) 2025 MintJams Inc. Licensed under MIT License.
8932
+ /**
8933
+ * Registry of custom element tag names defined via defineComponent().
8934
+ *
8935
+ * VNode consults this registry during compilation to recognize component
8936
+ * boundaries: the children of an already-expanded ichigo component belong to
8937
+ * the component's own VApplication and must not be compiled by the parent
8938
+ * application (doing so would strip the component's directives and evaluate
8939
+ * its template expressions in the wrong scope).
8940
+ */
8941
+ class IchigoElementRegistry {
8942
+ /**
8943
+ * Registered tag names, stored uppercased to match Node.nodeName.
8944
+ */
8945
+ static #tags = new Set();
8946
+ /**
8947
+ * Registers a component tag name. Called by defineComponent().
8948
+ * @param tagName The custom element tag name (case-insensitive).
8949
+ */
8950
+ static add(tagName) {
8951
+ this.#tags.add(tagName.toUpperCase());
8952
+ }
8953
+ /**
8954
+ * Checks whether a tag name belongs to a component defined via defineComponent().
8955
+ * @param tagName The tag name to check (case-insensitive; nodeName is accepted as-is).
8956
+ */
8957
+ static has(tagName) {
8958
+ return this.#tags.has(tagName.toUpperCase());
8959
+ }
8960
+ }
8961
+
8962
+ // Copyright (c) 2025 MintJams Inc. Licensed under MIT License.
8963
+ /**
8964
+ * Renders a scoped slot outlet: a `<slot>` element inside a component template
8965
+ * whose host element received a matching `<template v-slot:name="scope">` from
8966
+ * the parent.
8967
+ *
8968
+ * The slot content is compiled in the PARENT application's scope (the scope in
8969
+ * which the template was authored), extended with the slot props — the values
8970
+ * bound on the `<slot>` outlet with `v-bind` / `:` — exposed under the single
8971
+ * scope variable declared in `v-slot` (e.g. `v-slot:item="row"` makes
8972
+ * `row.node`, `row.index`, ... available):
8973
+ *
8974
+ * ```html
8975
+ * <!-- component template -->
8976
+ * <li v-for="(node, i) of items">
8977
+ * <slot name="item" :node="node" :index="i">fallback</slot>
8978
+ * </li>
8979
+ *
8980
+ * <!-- usage -->
8981
+ * <my-tree :items="nodes">
8982
+ * <template v-slot:item="row">{{ row.node.label }} ({{ appTitle }})</template>
8983
+ * </my-tree>
8984
+ * ```
8985
+ *
8986
+ * This directive is created only when a scoped template for the slot's name
8987
+ * exists (see {@link VSlotOutletDirective.tryCreate}); otherwise `<slot>`
8988
+ * elements keep their existing behavior (static distribution at expansion,
8989
+ * or fallback content compiled in the component scope).
8990
+ *
8991
+ * Rendering happens synchronously in onMount — before the component
8992
+ * application would compile the slot's fallback children — by replacing the
8993
+ * `<slot>` element with a clone of the scoped template's content. The content
8994
+ * VNode is attached under the host element's VNode in the parent application,
8995
+ * so parent-side data changes flow through the normal update cycle; slot-prop
8996
+ * changes on the component side are pushed by this directive's domUpdater.
8997
+ */
8998
+ class VSlotOutletDirective {
8999
+ /**
9000
+ * The virtual node of the `<slot>` element (in the component application).
9001
+ */
9002
+ #vNode;
9003
+ /**
9004
+ * The captured scoped template and its scope variable name.
9005
+ */
9006
+ #entry;
9007
+ /**
9008
+ * The host element's VNode in the parent application. Slot content is
9009
+ * compiled against this node's application and bindings.
9010
+ */
9011
+ #hostVNode;
9012
+ /**
9013
+ * The slot props: camelized names and evaluators for the `v-bind` / `:`
9014
+ * attributes found on the `<slot>` outlet (evaluated in component scope).
9015
+ */
9016
+ #props = [];
9017
+ /**
9018
+ * The bindings for the slot content: a child scope of the parent
9019
+ * application's bindings holding the scope variable.
9020
+ */
9021
+ #scopeBindings;
9022
+ /**
9023
+ * The VNode tree of the rendered slot content (owned by the parent application).
9024
+ */
9025
+ #contentVNode;
9026
+ /**
9027
+ * Guards against double destruction (the content VNode is reachable from
9028
+ * both the parent application's tree and this directive).
9029
+ */
9030
+ #destroyed = false;
9031
+ /**
9032
+ * Tracks host elements that already logged the "no host context" warning.
9033
+ */
9034
+ static #warnedNoHost = new WeakSet();
9035
+ /**
9036
+ * Creates a slot outlet directive for the given `<slot>` VNode when a
9037
+ * scoped template for its slot name was captured on the host element.
9038
+ * Returns undefined when there is nothing to do (no component host, no
9039
+ * scoped template for this name) so the `<slot>` element keeps its
9040
+ * existing behavior.
9041
+ */
9042
+ static tryCreate(vNode) {
9043
+ const host = vNode.vApplication.hostElement;
9044
+ if (!host) {
9045
+ return undefined;
9046
+ }
9047
+ const scopedSlots = host._ichigoScopedSlots;
9048
+ if (!scopedSlots || scopedSlots.size === 0) {
9049
+ return undefined;
9050
+ }
9051
+ const element = vNode.node;
9052
+ const slotName = element.getAttribute('name') || 'default';
9053
+ const entry = scopedSlots.get(slotName);
9054
+ if (!entry) {
9055
+ return undefined;
9056
+ }
9057
+ const hostVNode = host._ichigoHost?.vNode;
9058
+ if (!hostVNode) {
9059
+ // The scoped template cannot be rendered without the parent scope.
9060
+ // Leave the <slot> alone (fallback content renders in component scope).
9061
+ if (!VSlotOutletDirective.#warnedNoHost.has(host)) {
9062
+ VSlotOutletDirective.#warnedNoHost.add(host);
9063
+ 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.`);
9064
+ }
9065
+ return undefined;
9066
+ }
9067
+ return new VSlotOutletDirective(vNode, entry, hostVNode);
9068
+ }
9069
+ constructor(vNode, entry, hostVNode) {
9070
+ this.#vNode = vNode;
9071
+ this.#entry = entry;
9072
+ this.#hostVNode = hostVNode;
9073
+ // Collect and consume the slot-prop bindings (v-bind / :) so the
9074
+ // generic attribute parsing does not also process them.
9075
+ const element = vNode.node;
9076
+ for (const attr of Array.from(element.attributes)) {
9077
+ let propName;
9078
+ if (attr.name.startsWith('v-bind:')) {
9079
+ propName = attr.name.substring(7);
9080
+ }
9081
+ else if (attr.name.startsWith(':')) {
9082
+ propName = attr.name.substring(1);
9083
+ }
9084
+ if (!propName) {
9085
+ continue;
9086
+ }
9087
+ const evaluator = attr.value && vNode.bindings
9088
+ ? ExpressionEvaluator.create(attr.value, vNode.bindings, vNode.vApplication.functionDependencies)
9089
+ : undefined;
9090
+ this.#props.push({ name: this.#kebabToCamel(propName), evaluator });
9091
+ element.removeAttribute(attr.name);
9092
+ }
9093
+ }
9094
+ /**
9095
+ * @inheritdoc
9096
+ */
9097
+ get name() {
9098
+ return 'slot-outlet';
9099
+ }
9100
+ /**
9101
+ * @inheritdoc
9102
+ */
9103
+ get vNode() {
9104
+ return this.#vNode;
9105
+ }
9106
+ /**
9107
+ * @inheritdoc
9108
+ */
9109
+ get needsAnchor() {
9110
+ return false;
9111
+ }
9112
+ /**
9113
+ * @inheritdoc
9114
+ */
9115
+ get bindingsPreparer() {
9116
+ return undefined;
9117
+ }
9118
+ /**
9119
+ * @inheritdoc
9120
+ */
9121
+ get domUpdater() {
9122
+ const identifiers = this.dependentIdentifiers;
9123
+ const updater = {
9124
+ get dependentIdentifiers() {
9125
+ return identifiers;
9126
+ },
9127
+ applyToDOM: () => {
9128
+ this.#refresh();
9129
+ }
9130
+ };
9131
+ return updater;
9132
+ }
9133
+ /**
9134
+ * @inheritdoc
9135
+ */
9136
+ get templatize() {
9137
+ return false;
9138
+ }
9139
+ /**
9140
+ * @inheritdoc
9141
+ */
9142
+ get dependentIdentifiers() {
9143
+ const ids = new Set();
9144
+ for (const prop of this.#props) {
9145
+ prop.evaluator?.dependentIdentifiers.forEach(id => ids.add(id));
9146
+ }
9147
+ return Array.from(ids);
9148
+ }
9149
+ /**
9150
+ * @inheritdoc
9151
+ *
9152
+ * The initial render happens here — synchronously during VNode
9153
+ * construction, after the directives are created and BEFORE the component
9154
+ * application compiles the slot element's children. This guarantees the
9155
+ * outlet renders exactly once regardless of whether its slot props have
9156
+ * reactive identifiers, and removes the fallback children before they
9157
+ * could be compiled in the component scope.
9158
+ */
9159
+ get onMount() {
9160
+ return () => {
9161
+ this.#render();
9162
+ };
9163
+ }
9164
+ /**
9165
+ * @inheritdoc
9166
+ */
9167
+ get onMounted() {
9168
+ return undefined;
9169
+ }
9170
+ /**
9171
+ * @inheritdoc
9172
+ */
9173
+ get onUpdate() {
9174
+ return undefined;
9175
+ }
9176
+ /**
9177
+ * @inheritdoc
9178
+ */
9179
+ get onUpdated() {
9180
+ return undefined;
9181
+ }
9182
+ /**
9183
+ * @inheritdoc
9184
+ */
9185
+ get onUnmount() {
9186
+ return undefined;
9187
+ }
9188
+ /**
9189
+ * @inheritdoc
9190
+ */
9191
+ get onUnmounted() {
9192
+ return undefined;
9193
+ }
9194
+ /**
9195
+ * @inheritdoc
9196
+ */
9197
+ destroy() {
9198
+ if (this.#destroyed) {
9199
+ return;
9200
+ }
9201
+ this.#destroyed = true;
9202
+ // Destroy the content VNode so its dependency registrations on the
9203
+ // host VNode are released (critical for v-for rows that come and go).
9204
+ // The content DOM nodes live inside the component's subtree and are
9205
+ // removed together with it.
9206
+ this.#contentVNode?.destroy();
9207
+ this.#contentVNode = undefined;
9208
+ this.#scopeBindings = undefined;
9209
+ }
9210
+ /**
9211
+ * Renders the scoped slot content in place of the `<slot>` element.
9212
+ */
9213
+ #render() {
9214
+ const slot = this.#vNode.node;
9215
+ // Scope: parent application's bindings + the scope variable
9216
+ this.#scopeBindings = new VBindings({ parent: this.#hostVNode.bindings });
9217
+ if (this.#entry.scopeVar) {
9218
+ this.#scopeBindings.setLocal(this.#entry.scopeVar, this.#buildSlotProps());
9219
+ }
9220
+ // Clone the scoped template's content and create its VNode tree BEFORE
9221
+ // inserting into the DOM, so components inside the slot content are
9222
+ // compiled before their connectedCallback can expand them.
9223
+ // (Created through the parent application to keep the VNode import
9224
+ // type-only; a direct `new VNode` here would close a module cycle
9225
+ // VNode -> VDirectiveManager -> VSlotOutletDirective -> VNode.)
9226
+ const fragment = this.#entry.template.content.cloneNode(true);
9227
+ this.#contentVNode = this.#hostVNode.vApplication.createVNode({
9228
+ node: fragment,
9229
+ vApplication: this.#hostVNode.vApplication,
9230
+ parentVNode: this.#hostVNode,
9231
+ bindings: this.#scopeBindings
9232
+ });
9233
+ // Drop the fallback children (a scoped template supersedes them), then
9234
+ // swap the <slot> element for the content.
9235
+ while (slot.firstChild) {
9236
+ slot.removeChild(slot.firstChild);
9237
+ }
9238
+ slot.replaceWith(fragment);
9239
+ // Initial rendering
9240
+ this.#contentVNode.forceUpdate();
9241
+ }
9242
+ /**
9243
+ * Re-evaluates the slot props (component-side change) and pushes them into
9244
+ * the content scope.
9245
+ */
9246
+ #refresh() {
9247
+ if (!this.#contentVNode || !this.#scopeBindings || !this.#entry.scopeVar) {
9248
+ return;
9249
+ }
9250
+ this.#scopeBindings.setLocal(this.#entry.scopeVar, this.#buildSlotProps());
9251
+ this.#contentVNode.forceUpdate();
9252
+ }
9253
+ /**
9254
+ * Builds the slot props object from the outlet's `v-bind` attributes,
9255
+ * evaluated in the component scope.
9256
+ */
9257
+ #buildSlotProps() {
9258
+ const props = {};
9259
+ for (const prop of this.#props) {
9260
+ props[prop.name] = prop.evaluator?.evaluate();
9261
+ }
9262
+ return props;
9263
+ }
9264
+ #kebabToCamel(str) {
9265
+ return str.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
9266
+ }
9267
+ }
9268
+
8923
9269
  // Copyright (c) 2025 MintJams Inc. Licensed under MIT License.
8924
9270
  /**
8925
9271
  * Manages directives associated with a virtual node (VNode).
@@ -9086,6 +9432,24 @@ class VDirectiveManager {
9086
9432
  }
9087
9433
  #parseDirectives() {
9088
9434
  const element = this.#vNode.node;
9435
+ // Parse directives from attributes
9436
+ const directives = [];
9437
+ // Scoped slot outlet: a <slot> element (without structural directives)
9438
+ // inside a component whose host received a matching <template v-slot:...>.
9439
+ // This is an element-based directive, created before the generic
9440
+ // attribute parsing so it can consume the slot-prop bindings (:x).
9441
+ // A <slot> carrying v-if / v-for is handled structurally first; the
9442
+ // outlet is then created when the clone is compiled.
9443
+ if (element.tagName === 'SLOT' &&
9444
+ !element.hasAttribute(StandardDirectiveName.V_FOR) &&
9445
+ !element.hasAttribute(StandardDirectiveName.V_IF) &&
9446
+ !element.hasAttribute(StandardDirectiveName.V_ELSE_IF) &&
9447
+ !element.hasAttribute(StandardDirectiveName.V_ELSE)) {
9448
+ const outlet = VSlotOutletDirective.tryCreate(this.#vNode);
9449
+ if (outlet) {
9450
+ directives.push(outlet);
9451
+ }
9452
+ }
9089
9453
  // Collect relevant attributes
9090
9454
  // For v-for, v-if, v-else-if, v-else: process only the structural directive on the template element.
9091
9455
  // Other directives (@click, :class, etc.) will be processed on the cloned/rendered elements.
@@ -9121,8 +9485,6 @@ class VDirectiveManager {
9121
9485
  else {
9122
9486
  attributes.push(...Array.from(element.attributes));
9123
9487
  }
9124
- // Parse directives from attributes
9125
- const directives = [];
9126
9488
  for (const attribute of attributes) {
9127
9489
  // Create a context for parsing the directive
9128
9490
  const context = {
@@ -9515,7 +9877,14 @@ class VNode {
9515
9877
  }
9516
9878
  else if (this.#nodeType === Node.ELEMENT_NODE) {
9517
9879
  // If the node is an element, initialize directives and child nodes
9518
- this.#node;
9880
+ const element = this.#node;
9881
+ // If the element is a component defined via defineComponent(), install
9882
+ // the host context so component-boundary features (e.g. scoped slot
9883
+ // outlets) can reach back into this application's scope.
9884
+ const isComponent = IchigoElementRegistry.has(this.#nodeName);
9885
+ if (isComponent) {
9886
+ element._ichigoHost = { vNode: this };
9887
+ }
9519
9888
  // Initialize child virtual nodes
9520
9889
  this.#childVNodes = [];
9521
9890
  // Initialize directive manager
@@ -9528,8 +9897,19 @@ class VNode {
9528
9897
  d.onMount?.();
9529
9898
  });
9530
9899
  }
9900
+ // A component whose template is already expanded compiles its own
9901
+ // internals with its own VApplication — this (parent) application must
9902
+ // not adopt them (that would strip the component's directives and
9903
+ // evaluate its expressions in the wrong scope). An unexpanded component
9904
+ // element only contains authored slot content, which does belong to
9905
+ // this application's scope and is compiled normally.
9906
+ const isExpandedComponent = isComponent && element._ichigoExpanded === true;
9907
+ if (isExpandedComponent) {
9908
+ this.#vApplication.logManager.getLogger(this.constructor.name)
9909
+ .debug(`Skipped compiling internals of expanded component <${this.#nodeName.toLowerCase()}>`);
9910
+ }
9531
9911
  // Create child virtual nodes if template preservation is not required
9532
- if (!this.#templatized) {
9912
+ if (!this.#templatized && !isExpandedComponent) {
9533
9913
  for (const childNode of Array.from(this.#node.childNodes)) {
9534
9914
  new VNode({
9535
9915
  node: childNode,
@@ -11567,17 +11947,45 @@ class VModelDirective {
11567
11947
  * The modifiers for this v-model directive (e.g., "lazy", "number", "trim")
11568
11948
  */
11569
11949
  #modifiers = new Set();
11950
+ /**
11951
+ * The argument of the directive (e.g. "size" for `v-model:size`), camelized.
11952
+ * Only meaningful on components; selects the target prop and update event
11953
+ * (prop `size` + event `update:size`). Undefined = the default `modelValue`.
11954
+ */
11955
+ #arg;
11956
+ /**
11957
+ * Ensures the "v-model targets an undeclared prop" warning fires only once.
11958
+ */
11959
+ #warnedUndeclaredProp = false;
11570
11960
  /**
11571
11961
  * @param context The context for parsing the directive.
11572
11962
  */
11573
11963
  constructor(context) {
11574
11964
  this.#vNode = context.vNode;
11575
- // Extract modifiers from the directive name
11576
- // e.g., "v-model.lazy.trim" -> modifiers = ["lazy", "trim"]
11965
+ // Extract the optional argument and modifiers from the directive name
11966
+ // e.g. "v-model" / "v-model.lazy.trim" / "v-model:size" / "v-model:size.number"
11577
11967
  const attrName = context.attribute.name;
11578
- if (attrName.startsWith('v-model.')) {
11579
- const parts = attrName.split('.');
11580
- parts.slice(1).forEach(mod => this.#modifiers.add(mod));
11968
+ let rest = attrName.substring(StandardDirectiveName.V_MODEL.length);
11969
+ if (rest.startsWith(':')) {
11970
+ const dotIndex = rest.indexOf('.');
11971
+ const rawArg = dotIndex === -1 ? rest.substring(1) : rest.substring(1, dotIndex);
11972
+ if (rawArg) {
11973
+ // HTML attribute names are lowercased by the browser, so a
11974
+ // camelCase prop is addressed with kebab-case (v-model:inner-title).
11975
+ this.#arg = rawArg.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
11976
+ }
11977
+ rest = dotIndex === -1 ? '' : rest.substring(dotIndex);
11978
+ }
11979
+ if (rest.startsWith('.')) {
11980
+ rest.split('.').slice(1).forEach(mod => this.#modifiers.add(mod));
11981
+ }
11982
+ const element = context.vNode.node;
11983
+ if (this.#arg && !this.#isComponentElement(element)) {
11984
+ console.warn(`[ichigo] v-model:${this.#arg} is only supported on components; the argument is ignored on <${element.tagName.toLowerCase()}>`);
11985
+ this.#arg = undefined;
11986
+ }
11987
+ if (this.#isComponentElement(element) && this.#modifiers.has('lazy')) {
11988
+ console.warn(`[ichigo] <${element.tagName.toLowerCase()}>: the .lazy modifier has no effect on components (the component decides when to emit 'update:${this.#propName}')`);
11581
11989
  }
11582
11990
  // Parse the expression and create the evaluator
11583
11991
  const expression = context.attribute.value;
@@ -11749,6 +12157,14 @@ class VModelDirective {
11749
12157
  }
11750
12158
  // Evaluate the expression to get the value
11751
12159
  const value = this.#evaluator.evaluate();
12160
+ // Components: deliver the value as a property (prop `modelValue`, or the
12161
+ // directive argument). For ichigo components this reaches the reactive
12162
+ // bindings through the generated prop setter.
12163
+ if (this.#isComponentElement(element)) {
12164
+ const propName = this.#resolveComponentPropName(element);
12165
+ element[propName] = value;
12166
+ return;
12167
+ }
11752
12168
  // Update the element based on its type
11753
12169
  if (element instanceof HTMLInputElement) {
11754
12170
  if (element.type === 'checkbox') {
@@ -11782,6 +12198,19 @@ class VModelDirective {
11782
12198
  const element = this.#vNode.node;
11783
12199
  const eventName = this.#getEventName();
11784
12200
  this.#listener = (event) => {
12201
+ // Components: the payload of the `update:<prop>` CustomEvent is the
12202
+ // new value. Only events emitted by this component itself are
12203
+ // accepted — the default $emit fires on the component's root element
12204
+ // (a direct child of the host), so an event whose target lies deeper
12205
+ // was emitted by a nested component and bubbled through.
12206
+ if (this.#isComponentElement(element)) {
12207
+ const src = event.target;
12208
+ if (src !== element && src?.parentElement !== element) {
12209
+ return;
12210
+ }
12211
+ this.#updateBinding(this.#applyModifiers(event.detail));
12212
+ return;
12213
+ }
11785
12214
  const target = event.target;
11786
12215
  let newValue;
11787
12216
  // Get the new value based on element type
@@ -11936,11 +12365,52 @@ class VModelDirective {
11936
12365
  }
11937
12366
  return result;
11938
12367
  }
12368
+ /**
12369
+ * The target prop name on a component: the camelized directive argument,
12370
+ * or the `modelValue` convention when no argument is given.
12371
+ */
12372
+ get #propName() {
12373
+ return this.#arg ?? 'modelValue';
12374
+ }
12375
+ /**
12376
+ * Returns true when the bound element is a component (custom element).
12377
+ * The `modelValue` / `update:modelValue` contract works for any custom
12378
+ * element that follows it, not only ichigo components.
12379
+ */
12380
+ #isComponentElement(element) {
12381
+ return element.tagName.includes('-');
12382
+ }
12383
+ /**
12384
+ * Resolves the property name to assign on a component. For ichigo
12385
+ * components the declared prop list fixes up case-insensitive mismatches
12386
+ * (HTML-lowercased arguments vs. camelCase props) and an undeclared target
12387
+ * prop logs a one-time development warning.
12388
+ */
12389
+ #resolveComponentPropName(element) {
12390
+ const name = this.#propName;
12391
+ const props = element.constructor._props;
12392
+ if (Array.isArray(props)) {
12393
+ const lower = name.toLowerCase();
12394
+ const canonical = props.find(p => p.toLowerCase() === lower);
12395
+ if (canonical) {
12396
+ return canonical;
12397
+ }
12398
+ if (!this.#warnedUndeclaredProp) {
12399
+ this.#warnedUndeclaredProp = true;
12400
+ console.warn(`[ichigo] <${element.tagName.toLowerCase()}>: v-model targets prop '${name}', which is not declared in the component's props`);
12401
+ }
12402
+ }
12403
+ return name;
12404
+ }
11939
12405
  /**
11940
12406
  * Gets the appropriate event name for the element type.
11941
12407
  */
11942
12408
  #getEventName() {
11943
12409
  const element = this.#vNode.node;
12410
+ // Components: listen for the `update:<prop>` convention event.
12411
+ if (this.#isComponentElement(element)) {
12412
+ return 'update:' + this.#propName;
12413
+ }
11944
12414
  // .lazy modifier: use 'change' event instead of 'input'
11945
12415
  if (this.#modifiers.has('lazy')) {
11946
12416
  return 'change';
@@ -13680,9 +14150,10 @@ class VStandardDirectiveParser {
13680
14150
  // v-bind:<attribute>, :<attribute>
13681
14151
  context.attribute.name.startsWith(StandardDirectiveName.V_BIND + ":") ||
13682
14152
  context.attribute.name.startsWith(":") ||
13683
- // v-model, v-model.<modifier>
14153
+ // v-model, v-model.<modifier>, v-model:<arg>, v-model:<arg>.<modifier>
13684
14154
  context.attribute.name === StandardDirectiveName.V_MODEL ||
13685
14155
  context.attribute.name.startsWith(StandardDirectiveName.V_MODEL + ".") ||
14156
+ context.attribute.name.startsWith(StandardDirectiveName.V_MODEL + ":") ||
13686
14157
  // v-resize
13687
14158
  context.attribute.name === StandardDirectiveName.V_RESIZE ||
13688
14159
  // v-intersection
@@ -13750,9 +14221,10 @@ class VStandardDirectiveParser {
13750
14221
  context.attribute.name.startsWith(":")) {
13751
14222
  return new VBindDirective(context);
13752
14223
  }
13753
- // v-model, v-model.<modifier>
14224
+ // v-model, v-model.<modifier>, v-model:<arg>, v-model:<arg>.<modifier>
13754
14225
  if (context.attribute.name === StandardDirectiveName.V_MODEL ||
13755
- context.attribute.name.startsWith(StandardDirectiveName.V_MODEL + ".")) {
14226
+ context.attribute.name.startsWith(StandardDirectiveName.V_MODEL + ".") ||
14227
+ context.attribute.name.startsWith(StandardDirectiveName.V_MODEL + ":")) {
13756
14228
  return new VModelDirective(context);
13757
14229
  }
13758
14230
  // v-resize
@@ -14176,6 +14648,14 @@ class VApplication {
14176
14648
  * Flag to indicate if an update is already scheduled.
14177
14649
  */
14178
14650
  #updateScheduled = false;
14651
+ /**
14652
+ * The custom element hosting this application when it powers a component
14653
+ * defined via defineComponent(). Set by IchigoElement before mounting.
14654
+ * Used by component-boundary features (e.g. scoped slot outlets) to reach
14655
+ * the host's captured slot templates and parent application scope.
14656
+ * Undefined for regular (non-component) applications.
14657
+ */
14658
+ #hostElement;
14179
14659
  /**
14180
14660
  * Creates an instance of the virtual application.
14181
14661
  * @param args The initialization arguments for the application.
@@ -14279,9 +14759,32 @@ class VApplication {
14279
14759
  get refs() {
14280
14760
  return this.#refs;
14281
14761
  }
14762
+ /**
14763
+ * The custom element hosting this application when it powers a component
14764
+ * (see the #hostElement field). Undefined for regular applications.
14765
+ */
14766
+ get hostElement() {
14767
+ return this.#hostElement;
14768
+ }
14769
+ set hostElement(element) {
14770
+ this.#hostElement = element;
14771
+ }
14772
+ /**
14773
+ * Creates a VNode. Directives that need to build VNode trees at runtime
14774
+ * (e.g. the scoped slot outlet) call this instead of constructing VNode
14775
+ * directly, so they can keep their VNode import type-only and avoid a
14776
+ * VNode -> VDirectiveManager -> directive -> VNode module cycle.
14777
+ * @param init The initialization arguments for the virtual node.
14778
+ * @returns The created VNode.
14779
+ */
14780
+ createVNode(init) {
14781
+ return new VNode(init);
14782
+ }
14282
14783
  /**
14283
14784
  * Mounts the application.
14284
14785
  * @param target The CSS selector string or HTMLElement to mount the application to.
14786
+ * @returns This application instance, so `VDOM.createApp(...).mount('#app')`
14787
+ * can be assigned for later access (e.g. `app.bindings`, `app.unmount()`).
14285
14788
  */
14286
14789
  mount(target) {
14287
14790
  let element;
@@ -14312,6 +14815,7 @@ class VApplication {
14312
14815
  });
14313
14816
  element.removeAttribute('v-cloak');
14314
14817
  this.#logger.info('Application mounted.');
14818
+ return this;
14315
14819
  }
14316
14820
  /**
14317
14821
  * Unmounts the application and cleans up resources.
@@ -14362,7 +14866,15 @@ class VApplication {
14362
14866
  else {
14363
14867
  buffer = null;
14364
14868
  if (node.nodeType === Node.ELEMENT_NODE) {
14365
- this.#cleanElement(node);
14869
+ const child = node;
14870
+ // Do not clean inside an expanded component: its DOM belongs
14871
+ // to the component's own application, and removing rendered
14872
+ // (possibly empty) text nodes would detach them from that
14873
+ // application's virtual nodes, freezing its output.
14874
+ if (IchigoElementRegistry.has(child.tagName) && child._ichigoExpanded === true) {
14875
+ continue;
14876
+ }
14877
+ this.#cleanElement(child);
14366
14878
  }
14367
14879
  }
14368
14880
  }
@@ -14758,7 +15270,8 @@ class VApplication {
14758
15270
  */
14759
15271
  #emit(name, detail, options) {
14760
15272
  // Documentation/validation only: warn when emitting an event not declared in `emits`.
14761
- if (this.#options.emits && !this.#options.emits.includes(name)) {
15273
+ // `update:*` events are the component v-model convention and are always allowed.
15274
+ if (this.#options.emits && !name.startsWith('update:') && !this.#options.emits.includes(name)) {
14762
15275
  this.#logger.warn(`Event '${name}' is emitted but not declared in the 'emits' option.`);
14763
15276
  }
14764
15277
  const target = options?.target ?? this.#vNode?.node;
@@ -14861,13 +15374,19 @@ class VDOM {
14861
15374
  * Mount timing:
14862
15375
  * - If the component declares no props, the VApplication is mounted synchronously
14863
15376
  * at the end of connectedCallback (the template is known, no props to wait for).
14864
- * - If the component declares props, the VApplication is mounted the first time
14865
- * _setProp() is called after connectedCallback has prepared the DOM. This
14866
- * guarantees that the parent framework (e.g. ichigo.js VBindDirective) has
14867
- * already delivered at least one prop value before data() is evaluated.
15377
+ * - If the component declares props, a mount microtask is scheduled at the end of
15378
+ * connectedCallback. Prop values delivered synchronously by the parent framework
15379
+ * (VBindDirective runs within the parent's mount task) are stored before the
15380
+ * microtask fires, so data() can read them. A component used without any prop
15381
+ * binding still mounts — with its declared defaults — instead of waiting forever.
14868
15382
  *
14869
- * Subclasses must set the static fields _template, _props and _buildOptions before
14870
- * calling customElements.define(). defineComponent() handles this automatically.
15383
+ * Slot content is captured on the first connection and cached, so a component
15384
+ * that is moved in the DOM (e.g. reordered by v-for) re-expands its template
15385
+ * and redistributes the same slot nodes on reconnection.
15386
+ *
15387
+ * Subclasses must set the static fields _template, _props, _propOptions and
15388
+ * _buildOptions before calling customElements.define(). defineComponent()
15389
+ * handles this automatically.
14871
15390
  */
14872
15391
  class IchigoElement extends HTMLElement {
14873
15392
  /**
@@ -14887,43 +15406,118 @@ class IchigoElement extends HTMLElement {
14887
15406
  * Whether a mount microtask is already queued to avoid double-mounting.
14888
15407
  */
14889
15408
  #mountScheduled = false;
15409
+ /**
15410
+ * Slot content captured on the first connection. Reused on reconnection
15411
+ * (v-for reorder moves the host element, which re-runs connectedCallback
15412
+ * with the previously expanded template as children — not authored slot
15413
+ * content — so the original capture must be redistributed instead).
15414
+ * `scopedSlots` holds `<template v-slot:...>` children by slot name; they
15415
+ * are rendered per outlet by VSlotOutletDirective, not distributed here.
15416
+ */
15417
+ #slotCache;
15418
+ /**
15419
+ * True once this element's children are the expanded component template.
15420
+ * Read by VNode during compilation: a parent application must not compile
15421
+ * an expanded component's internals as its own template (the internals
15422
+ * belong to this component's VApplication and scope).
15423
+ */
15424
+ _ichigoExpanded = false;
15425
+ /**
15426
+ * Host context installed by the parent application's VNode when it
15427
+ * compiles this element. Gives component-boundary features (e.g. scoped
15428
+ * slot outlets) access to the parent scope. Undefined when the element is
15429
+ * placed outside any ichigo application.
15430
+ */
15431
+ _ichigoHost;
15432
+ /**
15433
+ * The scoped slot templates captured from this element's children, keyed
15434
+ * by slot name ('default' for `v-slot` without a name). Read by
15435
+ * VSlotOutletDirective when the component template renders a `<slot>`.
15436
+ */
15437
+ get _ichigoScopedSlots() {
15438
+ return this.#slotCache?.scopedSlots;
15439
+ }
14890
15440
  connectedCallback() {
14891
- // --- 0. Guard: skip if template not yet loaded ---
15441
+ // --- 0a. Guard: do not expand a template-source element ---
15442
+ // An element still carrying a templatizing directive attribute (v-if
15443
+ // family / v-for) is a template source: the owning application clones it
15444
+ // and inserts the clones (whose directive attributes are stripped during
15445
+ // clone compilation, so the clones expand normally). Expanding the source
15446
+ // here would bake the expansion into every clone, and the clone's
15447
+ // internals would then be compiled in the parent scope.
15448
+ if (this.hasAttribute(StandardDirectiveName.V_IF) ||
15449
+ this.hasAttribute(StandardDirectiveName.V_ELSE_IF) ||
15450
+ this.hasAttribute(StandardDirectiveName.V_ELSE) ||
15451
+ this.hasAttribute(StandardDirectiveName.V_FOR)) {
15452
+ return;
15453
+ }
15454
+ // --- 0b. Reclaim prop values assigned before upgrade ---
15455
+ // A plain property set on the instance (e.g. by a binding delivered
15456
+ // while the element was detached or not yet upgraded) is an own data
15457
+ // property that shadows the prototype accessor generated by
15458
+ // defineComponent — later assignments would keep writing to it and
15459
+ // never reach _setProp. Delete the own property and replay the value
15460
+ // through the accessor.
15461
+ const ctor = this.constructor;
15462
+ for (const prop of ctor._props) {
15463
+ if (Object.prototype.hasOwnProperty.call(this, prop)) {
15464
+ const value = this[prop];
15465
+ delete this[prop];
15466
+ this[prop] = value;
15467
+ }
15468
+ }
15469
+ // --- 0c. Guard: skip if template not yet loaded ---
14892
15470
  // customElements.define() may run before loadComponent() completes (e.g. when
14893
15471
  // dynamic imports are inlined by the bundler). The static placeholder element
14894
15472
  // in the HTML will be removed and replaced by v-for once the template is ready,
14895
15473
  // so it is safe to do nothing here.
14896
- const ctor = this.constructor;
14897
15474
  const templateEl = document.querySelector(ctor._template);
14898
15475
  if (!templateEl || !(templateEl instanceof HTMLTemplateElement)) {
14899
15476
  return;
14900
15477
  }
14901
- // --- 1. Capture slot content before clearing children ---
14902
- const defaultSlotNodes = [];
14903
- const namedSlotNodes = new Map();
14904
- for (const child of Array.from(this.childNodes)) {
14905
- if (child.nodeType === Node.ELEMENT_NODE) {
14906
- const el = child;
14907
- const slotName = el.getAttribute('slot');
14908
- if (slotName) {
14909
- if (!namedSlotNodes.has(slotName)) {
14910
- namedSlotNodes.set(slotName, []);
15478
+ // --- 1. Capture slot content (first connection only) ---
15479
+ if (!this.#slotCache) {
15480
+ const defaultNodes = [];
15481
+ const namedNodes = new Map();
15482
+ const scopedSlots = new Map();
15483
+ for (const child of Array.from(this.childNodes)) {
15484
+ if (child.nodeType === Node.ELEMENT_NODE) {
15485
+ const el = child;
15486
+ // <template v-slot:name="scopeVar"> — a scoped slot template.
15487
+ // Captured for per-outlet rendering (VSlotOutletDirective),
15488
+ // not distributed statically.
15489
+ if (el instanceof HTMLTemplateElement) {
15490
+ const scoped = this.#parseVSlotAttribute(el);
15491
+ if (scoped) {
15492
+ scopedSlots.set(scoped.name, { template: el, scopeVar: scoped.scopeVar });
15493
+ continue;
15494
+ }
15495
+ }
15496
+ const slotName = el.getAttribute('slot');
15497
+ if (slotName) {
15498
+ if (!namedNodes.has(slotName)) {
15499
+ namedNodes.set(slotName, []);
15500
+ }
15501
+ namedNodes.get(slotName).push(el);
15502
+ // Remove slot attribute so ichigo.js doesn't try to bind it
15503
+ el.removeAttribute('slot');
15504
+ }
15505
+ else {
15506
+ defaultNodes.push(el);
14911
15507
  }
14912
- namedSlotNodes.get(slotName).push(el);
14913
- // Remove slot attribute so ichigo.js doesn't try to bind it
14914
- el.removeAttribute('slot');
14915
- }
14916
- else {
14917
- defaultSlotNodes.push(el);
14918
15508
  }
14919
- }
14920
- else if (child.nodeType === Node.TEXT_NODE) {
14921
- if ((child.textContent ?? '').trim()) {
14922
- defaultSlotNodes.push(child);
15509
+ else if (child.nodeType === Node.TEXT_NODE) {
15510
+ if ((child.textContent ?? '').trim()) {
15511
+ defaultNodes.push(child);
15512
+ }
14923
15513
  }
14924
15514
  }
15515
+ this.#slotCache = { defaultNodes, namedNodes, scopedSlots };
14925
15516
  }
14926
- // Clear host element so we can append the cloned template
15517
+ const { defaultNodes, namedNodes } = this.#slotCache;
15518
+ // Clear host element so we can append the cloned template.
15519
+ // (On reconnection this discards the previous expansion; the cached slot
15520
+ // nodes are still referenced and are moved into the fresh clone below.)
14927
15521
  while (this.firstChild) {
14928
15522
  this.removeChild(this.firstChild);
14929
15523
  }
@@ -14931,7 +15525,7 @@ class IchigoElement extends HTMLElement {
14931
15525
  const fragment = templateEl.content.cloneNode(true);
14932
15526
  const root = this.#findRootElement(fragment);
14933
15527
  // --- 3. Distribute named slot content ---
14934
- for (const [name, nodes] of namedSlotNodes) {
15528
+ for (const [name, nodes] of namedNodes) {
14935
15529
  const slot = root.querySelector(`slot[name="${name}"]`);
14936
15530
  if (slot) {
14937
15531
  slot.replaceWith(...nodes);
@@ -14939,22 +15533,25 @@ class IchigoElement extends HTMLElement {
14939
15533
  }
14940
15534
  // --- 4. Distribute default slot content ---
14941
15535
  const defaultSlot = root.querySelector('slot:not([name])');
14942
- if (defaultSlot && defaultSlotNodes.length > 0) {
14943
- defaultSlot.replaceWith(...defaultSlotNodes);
15536
+ if (defaultSlot && defaultNodes.length > 0) {
15537
+ defaultSlot.replaceWith(...defaultNodes);
14944
15538
  }
14945
15539
  // Attach the populated template to the host element
14946
15540
  this.appendChild(root);
14947
15541
  this.#mountRoot = root;
14948
- // If props were set before connectedCallback, ensure mount is scheduled
14949
- this.#scheduleMountIfNeeded();
15542
+ this._ichigoExpanded = true;
14950
15543
  // --- 5. Mount strategy ---
14951
- // If this component has no declared props, mount immediately.
14952
- // If it has props, mount will be triggered from _setProp() once the parent
14953
- // delivers the first prop value (via VBindDirective / forceUpdate).
15544
+ // No declared props: mount immediately (nothing to wait for).
15545
+ // Declared props: schedule a mount microtask unconditionally. Prop values
15546
+ // pushed synchronously by the parent (VBindDirective) land before the
15547
+ // microtask runs; a component used without any binding still mounts,
15548
+ // with its declared defaults.
14954
15549
  if (ctor._props.length === 0) {
14955
15550
  this.#doMount();
14956
15551
  }
14957
- // else: wait for _setProp() to trigger #scheduleMountIfNeeded()
15552
+ else {
15553
+ this.#scheduleMountIfNeeded();
15554
+ }
14958
15555
  }
14959
15556
  disconnectedCallback() {
14960
15557
  this.#mountRoot = undefined;
@@ -14968,8 +15565,17 @@ class IchigoElement extends HTMLElement {
14968
15565
  * Called by the property setters generated by defineComponent().
14969
15566
  * Before mount: stores the value and schedules a mount microtask.
14970
15567
  * After mount: pushes the value directly into the reactive bindings.
15568
+ * An undefined value is replaced by the prop's declared default, if any.
14971
15569
  */
14972
15570
  _setProp(name, value) {
15571
+ const ctor = this.constructor;
15572
+ const opts = ctor._propOptions[name];
15573
+ if (opts) {
15574
+ if (value === undefined) {
15575
+ value = IchigoElement.#resolveDefault(opts);
15576
+ }
15577
+ this.#validateProp(name, value, opts);
15578
+ }
14973
15579
  this.#propValues[name] = value;
14974
15580
  if (this.#app) {
14975
15581
  this.#app.bindings?.set(name, value);
@@ -14990,9 +15596,16 @@ class IchigoElement extends HTMLElement {
14990
15596
  */
14991
15597
  static _template;
14992
15598
  /**
14993
- * List of declared prop names. Used to decide whether to defer mounting.
15599
+ * List of declared prop names. Used to decide whether to defer mounting,
15600
+ * and by VBindDirective to resolve kebab-case attributes to prop names.
14994
15601
  */
14995
15602
  static _props = [];
15603
+ /**
15604
+ * Per-prop declaration options (type / default / required / validator),
15605
+ * keyed by prop name. Normalized by defineComponent(): a prop declared
15606
+ * without options gets an empty object.
15607
+ */
15608
+ static _propOptions = {};
14996
15609
  /**
14997
15610
  * Factory that builds VApplicationOptions from the current prop values.
14998
15611
  * Implemented by defineComponent() as a closure that captures the user's options.
@@ -15001,7 +15614,8 @@ class IchigoElement extends HTMLElement {
15001
15614
  // --- Private helpers ---
15002
15615
  /**
15003
15616
  * Schedules a mount microtask if the DOM root is ready and no mount is pending.
15004
- * Called from _setProp() so the mount happens after the prop value is stored.
15617
+ * Called from connectedCallback and _setProp() so the mount happens after
15618
+ * synchronously delivered prop values are stored.
15005
15619
  */
15006
15620
  #scheduleMountIfNeeded() {
15007
15621
  if (this.#mountScheduled || this.#app || !this.#mountRoot) {
@@ -15018,10 +15632,85 @@ class IchigoElement extends HTMLElement {
15018
15632
  return;
15019
15633
  }
15020
15634
  const ctor = this.constructor;
15635
+ // Attribute fallback: a prop that never received a property value may
15636
+ // have been provided as a plain HTML attribute — static usage like
15637
+ // <my-badge label="x">, or a binding delivered by setAttribute before
15638
+ // the element was upgraded. Attribute values are strings; a declared
15639
+ // Boolean type turns presence ("") into true, and a declared Number
15640
+ // type parses numeric strings.
15641
+ for (const name of ctor._props) {
15642
+ if (this.#propValues[name] !== undefined) {
15643
+ continue;
15644
+ }
15645
+ const attr = this.getAttribute(IchigoElement.#camelToKebab(name)) ?? this.getAttribute(name.toLowerCase());
15646
+ if (attr === null) {
15647
+ continue;
15648
+ }
15649
+ const opts = ctor._propOptions[name];
15650
+ let value = attr;
15651
+ if (opts && IchigoElement.#typeIncludes(opts.type, Boolean) && attr === '') {
15652
+ value = true;
15653
+ }
15654
+ else if (opts && IchigoElement.#typeIncludes(opts.type, Number) && attr !== '' && !Number.isNaN(Number(attr))) {
15655
+ value = Number(attr);
15656
+ }
15657
+ if (opts) {
15658
+ this.#validateProp(name, value, opts);
15659
+ }
15660
+ this.#propValues[name] = value;
15661
+ }
15662
+ // Resolve defaults for props that never received a value, and report
15663
+ // missing required props (warning only; the mount proceeds either way).
15664
+ for (const [name, opts] of Object.entries(ctor._propOptions)) {
15665
+ if (this.#propValues[name] === undefined) {
15666
+ const resolved = IchigoElement.#resolveDefault(opts);
15667
+ if (resolved !== undefined) {
15668
+ this.#propValues[name] = resolved;
15669
+ }
15670
+ else if (opts.required) {
15671
+ this.#warn(`required prop '${name}' is missing`);
15672
+ }
15673
+ }
15674
+ }
15021
15675
  const options = ctor._buildOptions(this.#propValues);
15022
15676
  this.#app = VDOM.createApp(options);
15677
+ // Give the application a handle back to this host element so
15678
+ // component-boundary features (e.g. scoped slot outlets) can reach the
15679
+ // captured slot templates and the parent application scope.
15680
+ this.#app.hostElement = this;
15023
15681
  this.#app.mount(this.#mountRoot);
15024
15682
  }
15683
+ /**
15684
+ * Parses a `v-slot` declaration on a `<template>` child, accepting
15685
+ * `v-slot` / `v-slot:name` and the `#name` shorthand. Returns the slot
15686
+ * name ('default' when omitted) and the scope variable name, or undefined
15687
+ * when the template carries no v-slot attribute. The scope variable must
15688
+ * be a single identifier; an invalid one is ignored with a warning.
15689
+ */
15690
+ #parseVSlotAttribute(template) {
15691
+ for (const attr of Array.from(template.attributes)) {
15692
+ let name;
15693
+ if (attr.name === 'v-slot') {
15694
+ name = 'default';
15695
+ }
15696
+ else if (attr.name.startsWith('v-slot:')) {
15697
+ name = attr.name.substring(7);
15698
+ }
15699
+ else if (attr.name.startsWith('#')) {
15700
+ name = attr.name.substring(1);
15701
+ }
15702
+ if (name === undefined || name === '') {
15703
+ continue;
15704
+ }
15705
+ let scopeVar = attr.value.trim();
15706
+ if (scopeVar && !/^[A-Za-z_$][\w$]*$/.test(scopeVar)) {
15707
+ this.#warn(`v-slot scope must be a single identifier; '${scopeVar}' is ignored (destructuring is not supported)`);
15708
+ scopeVar = '';
15709
+ }
15710
+ return { name, scopeVar };
15711
+ }
15712
+ return undefined;
15713
+ }
15025
15714
  #findRootElement(fragment) {
15026
15715
  for (const node of Array.from(fragment.childNodes)) {
15027
15716
  if (node.nodeType === Node.ELEMENT_NODE) {
@@ -15030,6 +15719,74 @@ class IchigoElement extends HTMLElement {
15030
15719
  }
15031
15720
  throw new Error(`IchigoElement: no root element found in template '${this.constructor._template}'`);
15032
15721
  }
15722
+ /**
15723
+ * Resolves a prop's default value. A function default is called as a
15724
+ * factory unless the declared type includes Function, in which case the
15725
+ * function itself is the value.
15726
+ */
15727
+ static #resolveDefault(opts) {
15728
+ const def = opts.default;
15729
+ if (def === undefined) {
15730
+ return undefined;
15731
+ }
15732
+ if (typeof def === 'function' && !IchigoElement.#typeIncludes(opts.type, Function)) {
15733
+ return def();
15734
+ }
15735
+ return def;
15736
+ }
15737
+ static #typeIncludes(type, ctor) {
15738
+ if (!type) {
15739
+ return false;
15740
+ }
15741
+ return Array.isArray(type) ? type.includes(ctor) : type === ctor;
15742
+ }
15743
+ static #camelToKebab(str) {
15744
+ return str.replace(/([A-Z])/g, '-$1').toLowerCase();
15745
+ }
15746
+ /**
15747
+ * Validates a delivered prop value against its declaration.
15748
+ * Warnings only — the value is stored and delivered regardless.
15749
+ * null/undefined values skip both checks (undefined is handled by
15750
+ * default/required; null is an explicit "no value").
15751
+ */
15752
+ #validateProp(name, value, opts) {
15753
+ if (value === null || value === undefined) {
15754
+ return;
15755
+ }
15756
+ if (opts.type) {
15757
+ const types = Array.isArray(opts.type) ? opts.type : [opts.type];
15758
+ if (!types.some(t => IchigoElement.#matchesType(value, t))) {
15759
+ const expected = types.map(t => t.name ?? String(t)).join(' | ');
15760
+ const actual = typeof value === 'object'
15761
+ ? (value?.constructor?.name ?? 'object')
15762
+ : typeof value;
15763
+ this.#warn(`prop '${name}' expects ${expected}, got ${actual}`);
15764
+ }
15765
+ }
15766
+ if (opts.validator && !opts.validator(value)) {
15767
+ this.#warn(`prop '${name}' failed its validator`);
15768
+ }
15769
+ }
15770
+ static #matchesType(value, type) {
15771
+ switch (type) {
15772
+ case String: return typeof value === 'string';
15773
+ case Number: return typeof value === 'number';
15774
+ case Boolean: return typeof value === 'boolean';
15775
+ case Function: return typeof value === 'function';
15776
+ case Array: return Array.isArray(value);
15777
+ case Object: return typeof value === 'object' && value !== null && !Array.isArray(value);
15778
+ default:
15779
+ try {
15780
+ return value instanceof type;
15781
+ }
15782
+ catch {
15783
+ return false;
15784
+ }
15785
+ }
15786
+ }
15787
+ #warn(message) {
15788
+ console.warn(`[ichigo] <${this.tagName.toLowerCase()}>: ${message}`);
15789
+ }
15033
15790
  }
15034
15791
 
15035
15792
  // Copyright (c) 2025 MintJams Inc. Licensed under MIT License.
@@ -15050,9 +15807,8 @@ class IchigoElement extends HTMLElement {
15050
15807
  * ```typescript
15051
15808
  * defineComponent('my-list', {
15052
15809
  * template: '#my-list',
15053
- * props: ['items'],
15054
- * data() {
15055
- * return { items: this.items ?? [] };
15810
+ * props: {
15811
+ * items: { type: Array, default: () => [] },
15056
15812
  * },
15057
15813
  * });
15058
15814
  * ```
@@ -15062,15 +15818,34 @@ class IchigoElement extends HTMLElement {
15062
15818
  * </my-list>
15063
15819
  * ```
15064
15820
  *
15821
+ * Props may be declared as a simple name list (`props: ['items']`) or as a
15822
+ * record with per-prop options (type / default / required / validator).
15823
+ * See {@link PropOptions} for the declaration semantics.
15824
+ *
15065
15825
  * @param tagName Custom element tag name (must contain a hyphen, e.g. 'my-card').
15066
15826
  * @param options Component options including template selector and optional props.
15067
15827
  */
15068
15828
  function defineComponent(tagName, options) {
15069
15829
  const { props = [], template, data, computed, methods, watch, emits, logLevel } = options;
15830
+ // Normalize the props declaration: a plain name list becomes a record of
15831
+ // empty option objects so the rest of the code handles a single shape.
15832
+ const propOptions = {};
15833
+ if (Array.isArray(props)) {
15834
+ for (const name of props) {
15835
+ propOptions[name] = {};
15836
+ }
15837
+ }
15838
+ else {
15839
+ for (const [name, opts] of Object.entries(props)) {
15840
+ propOptions[name] = opts ?? {};
15841
+ }
15842
+ }
15843
+ const propNames = Object.keys(propOptions);
15070
15844
  // Build a subclass of IchigoElement specific to this component
15071
15845
  class ComponentElement extends IchigoElement {
15072
15846
  static _template = template;
15073
- static _props = props;
15847
+ static _props = propNames;
15848
+ static _propOptions = propOptions;
15074
15849
  static _buildOptions(propValues) {
15075
15850
  return {
15076
15851
  data() {
@@ -15095,7 +15870,7 @@ function defineComponent(tagName, options) {
15095
15870
  }
15096
15871
  // Generate a property getter/setter for each declared prop.
15097
15872
  // This enables the parent VApplication to push updates via `element.propName = value`.
15098
- for (const prop of props) {
15873
+ for (const prop of propNames) {
15099
15874
  Object.defineProperty(ComponentElement.prototype, prop, {
15100
15875
  get() {
15101
15876
  return this._getProp(prop);
@@ -15108,6 +15883,8 @@ function defineComponent(tagName, options) {
15108
15883
  });
15109
15884
  }
15110
15885
  customElements.define(tagName, ComponentElement);
15886
+ // Record the tag so VNode can recognize component boundaries during compilation.
15887
+ IchigoElementRegistry.add(tagName);
15111
15888
  }
15112
15889
 
15113
15890
  export { ExpressionUtils, IchigoElement, ReactiveProxy, VComponent, VComponentRegistry, VDOM, defineComponent, toDisplayString };