@mintjamsinc/ichigojs 0.1.68 → 0.1.69

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/README.md CHANGED
@@ -9,14 +9,17 @@ A simple and intuitive reactive framework. Lightweight, fast, and user-friendly
9
9
 
10
10
  - ✨ **Vue-like API** - Familiar syntax for Vue developers
11
11
  - ⚡ **Reactive Proxy System** - Automatic change detection without manual triggers
12
- - 🎯 **Computed Properties** - Automatic dependency tracking and re-evaluation
12
+ - 🎯 **Computed Properties** - Automatic dependency tracking and re-evaluation, including writable computed (`{ get, set }`)
13
+ - 👀 **Watchers** - React to data changes with the `watch` option (`deep`, `immediate`)
13
14
  - 🔄 **Two-way Binding** - `v-model` with modifiers (`.lazy`, `.number`, `.trim`)
14
15
  - 🔌 **Lifecycle Hooks** - `@mount`, `@mounted`, `@update`, `@updated`, `@unmount`, `@unmounted` with context (`$ctx`)
15
16
  - 💾 **userData Storage** - Proxy-free storage for third-party library instances with auto-cleanup
17
+ - 🧩 **Components** - Reusable Web Components via `defineComponent` with `props`, `slot`, and `$emit`
16
18
  - 📦 **Lightweight** - Minimal bundle size
17
19
  - 🚀 **High Performance** - Efficient batched updates via microtask queue
18
20
  - 💪 **TypeScript** - Written in TypeScript with full type support
19
- - 🎨 **Directives** - `v-if`, `v-for`, `v-show`, `v-bind`, `v-on`, `v-model`, `v-resize`, `v-intersection`, `v-performance`
21
+ - 🎨 **Directives** - `v-if`, `v-else-if`, `v-else`, `v-for`, `v-show`, `v-bind`, `v-on`, `v-model`, `v-text`, `v-html`, `v-focus`, `v-resize`, `v-intersection`, `v-performance`
22
+ - 🎯 **Focus Management** - Declarative focus control with the `v-focus` directive (`.select`, `.cursor-end`)
20
23
  - 📐 **Resize Observer** - Monitor element size changes with `v-resize` directive
21
24
  - 👁️ **Intersection Observer** - Detect element visibility with `v-intersection` directive
22
25
  - ⚡ **Performance Observer** - Monitor performance metrics with `v-performance` directive
@@ -195,6 +198,84 @@ VDOM.createApp({
195
198
  }).mount('#app');
196
199
  ```
197
200
 
201
+ **Writable Computed Properties:**
202
+
203
+ A computed property can also be defined as an object with both a `get` and a
204
+ `set` function. This makes it writable, so it can be used as a `v-model` target
205
+ or assigned to directly. Reads go through `get`, while assignments are routed
206
+ through `set`.
207
+
208
+ ```javascript
209
+ VDOM.createApp({
210
+ data() {
211
+ return {
212
+ firstName: 'John',
213
+ lastName: 'Doe'
214
+ };
215
+ },
216
+ computed: {
217
+ fullName: {
218
+ get() {
219
+ return `${this.firstName} ${this.lastName}`;
220
+ },
221
+ set(value) {
222
+ const [first, last] = value.split(' ');
223
+ this.firstName = first;
224
+ this.lastName = last;
225
+ }
226
+ }
227
+ }
228
+ }).mount('#app');
229
+ ```
230
+
231
+ ```html
232
+ <!-- Assigning through v-model invokes the computed setter -->
233
+ <input v-model="fullName">
234
+ ```
235
+
236
+ ### Watchers
237
+
238
+ Use the `watch` option to run a callback whenever a watched property changes.
239
+ Keys are property paths (e.g. `"count"`, `"user.name"`), and the callback
240
+ receives the new and previous values.
241
+
242
+ ```javascript
243
+ VDOM.createApp({
244
+ data() {
245
+ return {
246
+ count: 0,
247
+ user: { name: 'Alice' }
248
+ };
249
+ },
250
+ watch: {
251
+ // Shorthand: a callback function
252
+ count(newValue, oldValue) {
253
+ console.log(`count changed from ${oldValue} to ${newValue}`);
254
+ },
255
+
256
+ // Watch a nested property by path
257
+ 'user.name'(newValue, oldValue) {
258
+ console.log(`name changed from ${oldValue} to ${newValue}`);
259
+ },
260
+
261
+ // Full form: an options object with deep / immediate
262
+ user: {
263
+ handler(newValue, oldValue) {
264
+ console.log('user object changed', newValue);
265
+ },
266
+ deep: true, // Observe nested changes inside the object
267
+ immediate: true // Invoke once immediately with the current value
268
+ }
269
+ }
270
+ }).mount('#app');
271
+ ```
272
+
273
+ **Watcher options:**
274
+
275
+ - `handler` - The callback invoked when the watched value changes
276
+ - `deep` - When `true`, deeply observes nested object/array changes (default: `false`)
277
+ - `immediate` - When `true`, invokes the handler once immediately on registration with the current value (default: `false`)
278
+
198
279
  ### Directives
199
280
 
200
281
  #### v-if / v-else-if / v-else
@@ -314,6 +395,73 @@ methods: {
314
395
  }
315
396
  ```
316
397
 
398
+ #### v-text
399
+
400
+ Set the text content of an element. The expression result replaces the
401
+ element's `textContent`. Unlike `v-html`, the content is rendered as plain
402
+ text, so HTML is escaped and XSS is not a concern.
403
+
404
+ ```html
405
+ <span v-text="message"></span>
406
+
407
+ <!-- Equivalent to -->
408
+ <span>{{ message }}</span>
409
+ ```
410
+
411
+ Use `v-text` when you want to set the entire text content of an element from a
412
+ single expression (it overwrites any existing content), rather than
413
+ interpolating with `{{ }}`.
414
+
415
+ #### v-html
416
+
417
+ Set the raw HTML content of an element. The expression result is assigned to
418
+ the element's `innerHTML`.
419
+
420
+ ```html
421
+ <div v-html="htmlContent"></div>
422
+ ```
423
+
424
+ > ⚠️ **Security warning:** Dynamically rendering arbitrary HTML can easily lead
425
+ > to XSS attacks. Only use `v-html` on **trusted** content, and **never** on
426
+ > user-provided content. For plain text, use `v-text` or `{{ }}` interpolation
427
+ > instead.
428
+
429
+ #### v-focus
430
+
431
+ Declaratively manage focus on an element. Focus is deferred via
432
+ `requestAnimationFrame`, so elements that become visible just before the
433
+ directive runs (for example inside a `v-if` or a `display: none` container)
434
+ still receive focus reliably.
435
+
436
+ ```html
437
+ <!-- Focus once after mount -->
438
+ <input v-focus>
439
+
440
+ <!-- Focus + select all text after mount -->
441
+ <input v-focus.select>
442
+
443
+ <!-- Focus + place the caret at the end of the value -->
444
+ <input v-focus.cursor-end value="prefilled">
445
+
446
+ <!-- Conditional focus: fires when the expression goes from falsy to truthy -->
447
+ <input v-focus="isEditing">
448
+
449
+ <!-- Conditional focus + select all -->
450
+ <input v-focus.select="isEditing">
451
+ ```
452
+
453
+ **Behavior:**
454
+
455
+ - **Without an expression**, the element is focused exactly once after mount.
456
+ - **With an expression**, focus fires only on the falsy → truthy edge, so the
457
+ user is not repeatedly re-focused on every reactive update. If the value is
458
+ already truthy on mount, the element is focused immediately.
459
+
460
+ **Modifiers:**
461
+
462
+ - `.select` - After focusing, selects all text in the input/textarea
463
+ - `.cursor-end` - After focusing, places the caret at the end of the value
464
+
317
465
  #### v-resize
318
466
 
319
467
  Monitor element size changes using ResizeObserver:
@@ -591,9 +739,20 @@ Two-way data binding:
591
739
  <input v-model.number="age"> <!-- Convert to number -->
592
740
  <input v-model.trim="username"> <!-- Trim whitespace -->
593
741
 
594
- <!-- Checkbox -->
742
+ <!-- Checkbox (boolean) -->
595
743
  <input type="checkbox" v-model="isChecked">
596
744
 
745
+ <!-- Checkbox with custom true/false values -->
746
+ <input type="checkbox" v-model="status" :true-value="'yes'" :false-value="'no'">
747
+
748
+ <!-- Checkbox group bound to an array -->
749
+ <input type="checkbox" value="a" v-model="selectedItems">
750
+ <input type="checkbox" value="b" v-model="selectedItems">
751
+
752
+ <!-- Radio -->
753
+ <input type="radio" value="a" v-model="picked">
754
+ <input type="radio" value="b" v-model="picked">
755
+
597
756
  <!-- Select -->
598
757
  <select v-model="selected">
599
758
  <option value="a">Option A</option>
@@ -601,6 +760,13 @@ Two-way data binding:
601
760
  </select>
602
761
  ```
603
762
 
763
+ **Supported elements:**
764
+
765
+ - **Text inputs / `<textarea>`** - Binds to the element's value
766
+ - **Checkbox** - Binds to a boolean, to a custom value pair via `:true-value` / `:false-value`, or to an array (when the bound value is an array, the checkbox's `value` is added/removed)
767
+ - **Radio** - Binds to the `value` (or `:value`) of the selected radio button
768
+ - **Select** - Binds to the selected option's value (re-applied automatically when options are generated dynamically via `v-for`)
769
+
604
770
  ### Methods
605
771
 
606
772
  Methods have access to data and computed properties via `this`:
@@ -639,6 +805,108 @@ methods: {
639
805
  }
640
806
  ```
641
807
 
808
+ ## Components
809
+
810
+ ichigo.js components are real [Custom Elements](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements)
811
+ backed by the same reactivity system. Define a component with `defineComponent`,
812
+ pointing it at a `<template>` for its markup.
813
+
814
+ ```html
815
+ <!-- Component markup -->
816
+ <template id="my-list">
817
+ <ul v-if="items.length > 0">
818
+ <li v-for="item of items" :key="item.id">{{ item.name }}</li>
819
+ </ul>
820
+ <!-- Fallback content projected from the parent -->
821
+ <slot></slot>
822
+ </template>
823
+ ```
824
+
825
+ ```javascript
826
+ import { defineComponent } from '@mintjamsinc/ichigojs';
827
+
828
+ defineComponent('my-list', {
829
+ template: '#my-list', // CSS selector for the <template>
830
+ props: ['items'], // Props received from the parent
831
+ data() {
832
+ // Props are accessible via `this` and can be defaulted/transformed here
833
+ return { items: this.items ?? [] };
834
+ }
835
+ });
836
+ ```
837
+
838
+ ```html
839
+ <!-- Usage -->
840
+ <my-list :items="searchResults">
841
+ <span slot="empty">No results.</span>
842
+ </my-list>
843
+ ```
844
+
845
+ **Props:**
846
+
847
+ - Declared via the `props` array. Each declared prop becomes a property on the
848
+ custom element, so the parent can bind to it with `v-bind` / `:`
849
+ (e.g. `:items="searchResults"`).
850
+ - Props are reactive from the start and are included in the component's data
851
+ automatically. Values returned from `data()` take precedence, allowing you to
852
+ default or transform a prop (e.g. `this.items ?? []`).
853
+
854
+ **Slots:**
855
+
856
+ Use the native `<slot>` element in the component template to project content
857
+ from the parent. ichigo.js components use Light DOM.
858
+
859
+ ### Events (`$emit`)
860
+
861
+ Components (and applications) can dispatch custom events with `$emit`, which is
862
+ available in both templates and methods. By default the event bubbles from the
863
+ component's root element, so a parent can listen for it with `v-on` / `@` on the
864
+ component tag.
865
+
866
+ ```javascript
867
+ defineComponent('my-button', {
868
+ template: '#my-button',
869
+ // Optional: declare the events this component emits.
870
+ // Emitting an undeclared event logs a development warning (validation only;
871
+ // it never blocks dispatch). Omit `emits` to allow any event name.
872
+ emits: ['selected'],
873
+ methods: {
874
+ onClick() {
875
+ // $emit(name, detail?, options?)
876
+ this.$emit('selected', { id: 42 });
877
+ }
878
+ }
879
+ });
880
+ ```
881
+
882
+ ```html
883
+ <!-- Parent listens for the custom event; payload is in event.detail -->
884
+ <my-button @selected="onSelected"></my-button>
885
+ ```
886
+
887
+ **`$emit(name, detail?, options?)`:**
888
+
889
+ - `name` - The event name (listened to as `@name` on the parent)
890
+ - `detail` - The payload exposed as `event.detail`
891
+ - `options` - Dispatch options (`VEmitOptions`):
892
+ - `bubbles` - Whether the event bubbles (default: `true`)
893
+ - `cancelable` - Whether `preventDefault()` has an effect (default: `true`); `$emit` returns `false` when a listener calls `preventDefault()`
894
+ - `composed` - Whether the event crosses shadow DOM boundaries (default: `false`)
895
+ - `target` - The dispatch target (default: the application root element). Set to `document` / `window` for a global event bus.
896
+
897
+ ### Legacy component directive (`v-component`)
898
+
899
+ > ⚠️ **Deprecated.** The `v-component` directive and the `VComponentRegistry`
900
+ > are deprecated and will be removed in a future release. Use
901
+ > [`defineComponent`](#components) (Custom Elements) for new code.
902
+
903
+ For reference, the legacy mechanism renders a component registered in the
904
+ application's `VComponentRegistry` by id, passing props through `:options`:
905
+
906
+ ```html
907
+ <div v-component="my-component" :options="{ message: 'Hello' }"></div>
908
+ ```
909
+
642
910
  ## Performance
643
911
 
644
912
  ichigo.js uses several optimization techniques:
@@ -696,13 +964,33 @@ Creates a new application instance.
696
964
 
697
965
  **Options:**
698
966
 
699
- - `data()`: Function that returns the initial data object
700
- - `computed`: Object containing computed property definitions
967
+ - `data()`: Function that returns the initial data object. Called with a `$ctx` (`{ $markRaw }`) as `this`.
968
+ - `computed`: Object containing computed property definitions. Each value is either a getter function (read-only) or a `{ get, set }` object (writable).
701
969
  - `methods`: Object containing method definitions
970
+ - `watch`: Object mapping property paths to watcher definitions (a callback, or `{ handler, deep, immediate }`)
971
+ - `emits`: Optional array of event names the app/component is expected to emit via `$emit`. Emitting an undeclared event logs a development warning (validation only).
702
972
  - `logLevel`: Logging level (`'debug'` | `'info'` | `'warn'` | `'error'`)
703
973
 
704
974
  **Returns:** Application instance with `mount(selector)` method
705
975
 
976
+ **Instance helpers** (available in `data()`, methods, expressions, and lifecycle hooks as appropriate):
977
+
978
+ - `$markRaw(obj)`: Marks an object as non-reactive (see [Marking Objects as Non-Reactive](#marking-objects-as-non-reactive))
979
+ - `$nextTick(callback)`: Runs a callback after the next DOM update
980
+ - `$emit(name, detail?, options?)`: Dispatches a custom event (see [Events](#events-emit))
981
+ - `$ctx`: Lifecycle/handler context with `element`, `vnode`, and `userData`
982
+
983
+ ### defineComponent(tagName, options)
984
+
985
+ Defines and registers a custom element backed by ichigo.js reactivity. See [Components](#components).
986
+
987
+ **Options** (extends the `createApp` options above):
988
+
989
+ - `template`: CSS selector for the `<template>` element that defines the component's markup (required)
990
+ - `props`: Array of property names received from the parent via attribute/property binding
991
+
992
+ **Returns:** `void` (the custom element is registered via `customElements.define`)
993
+
706
994
  ## Contributing
707
995
 
708
996
  Contributions are welcome! Please feel free to submit a Pull Request.
package/dist/ichigo.cjs CHANGED
@@ -10270,9 +10270,18 @@
10270
10270
  #sourceName;
10271
10271
  #useOfSyntax = false; // Track if 'of' syntax was used
10272
10272
  /**
10273
- * Map to track rendered items by their keys
10273
+ * Ordered list of currently rendered items.
10274
+ *
10275
+ * This is intentionally an ordered array of { key, vNode } entries rather
10276
+ * than a Map<key, VNode>. The :key attribute is a *reconciliation hint*
10277
+ * used to identify and reuse the same logical row across re-renders — it is
10278
+ * not the identity of the rendered set. Keying the rendered set by a Map
10279
+ * would make it structurally impossible to hold two rows that resolve to
10280
+ * the same key, which would silently drop application data. The most
10281
+ * fundamental invariant of a list directive is "N items in => N rows out",
10282
+ * so the rendered set must be a positional list that can carry duplicates.
10274
10283
  */
10275
- #renderedItems = new Map();
10284
+ #renderedItems = [];
10276
10285
  /**
10277
10286
  * Previous iterations to detect changes
10278
10287
  */
@@ -10395,11 +10404,11 @@
10395
10404
  destroy() {
10396
10405
  // Clean up all rendered items
10397
10406
  // First destroy all VNodes (calls @unmount hooks), then remove from DOM
10398
- for (const vNode of this.#renderedItems.values()) {
10407
+ for (const { vNode } of this.#renderedItems) {
10399
10408
  vNode.destroy();
10400
10409
  }
10401
10410
  // Then remove DOM nodes
10402
- for (const vNode of this.#renderedItems.values()) {
10411
+ for (const { vNode } of this.#renderedItems) {
10403
10412
  const range = vNode.fragmentRange;
10404
10413
  if (range) {
10405
10414
  range.remove();
@@ -10410,7 +10419,7 @@
10410
10419
  vNode.node.parentNode.removeChild(vNode.node);
10411
10420
  }
10412
10421
  }
10413
- this.#renderedItems.clear();
10422
+ this.#renderedItems = [];
10414
10423
  this.#previousIterations = [];
10415
10424
  }
10416
10425
  /**
@@ -10451,7 +10460,24 @@
10451
10460
  this.#previousIterations = iterations;
10452
10461
  }
10453
10462
  /**
10454
- * Key-based diffing for efficient DOM updates
10463
+ * Key-based diffing for efficient DOM updates.
10464
+ *
10465
+ * Reconciliation model
10466
+ * --------------------
10467
+ * The :key attribute is treated as a *hint* for reusing the same logical
10468
+ * row across re-renders, not as the identity of the rendered set. The
10469
+ * previously rendered rows are placed into a pool keyed by :key (a queue
10470
+ * per key, so equal keys can hold more than one row). Each incoming
10471
+ * iteration then claims a row from its key's queue when one is available,
10472
+ * otherwise a fresh row is created. Whatever stays in the pool at the end
10473
+ * is genuinely gone and is destroyed and removed.
10474
+ *
10475
+ * This guarantees the directive's most fundamental invariant — "N items in
10476
+ * => N rows out" — even when the application supplies duplicate keys. We
10477
+ * still warn on duplicates because they make reuse ambiguous (reordering
10478
+ * becomes positional rather than identity-stable), but we never silently
10479
+ * drop the application's data, and no row is ever orphaned: every old row
10480
+ * is either reused or explicitly removed.
10455
10481
  */
10456
10482
  #updateList(newIterations) {
10457
10483
  const parent = this.#vNode.anchorNode?.parentNode;
@@ -10459,22 +10485,38 @@
10459
10485
  if (!parent || !anchor) {
10460
10486
  throw new Error('v-for element must have a parent and anchor');
10461
10487
  }
10462
- const newRenderedItems = new Map();
10463
- // Track which keys are still needed and detect duplicates
10464
- const neededKeys = new Set();
10488
+ // Build a reuse pool from the currently rendered rows. A queue per key
10489
+ // (FIFO) lets duplicate keys reuse multiple rows: the first incoming
10490
+ // occurrence claims the first existing row, the second claims the next,
10491
+ // and so on.
10492
+ const pool = new Map();
10493
+ for (const { key, vNode } of this.#renderedItems) {
10494
+ let queue = pool.get(key);
10495
+ if (!queue) {
10496
+ queue = [];
10497
+ pool.set(key, queue);
10498
+ }
10499
+ queue.push(vNode);
10500
+ }
10501
+ // Decide, for each incoming iteration in order, whether it reuses an
10502
+ // existing row or needs a new one. Reused rows are taken out of the
10503
+ // pool so that what remains afterwards is exactly the set to remove.
10465
10504
  const seenKeys = new Set();
10466
- for (const ctx of newIterations) {
10467
- if (seenKeys.has(ctx.key)) {
10468
- console.warn(`[ichigo.js] Duplicate key detected in v-for: "${ctx.key}". This may cause unexpected behavior. Keys should be unique.`);
10505
+ const plan = [];
10506
+ for (const context of newIterations) {
10507
+ if (seenKeys.has(context.key)) {
10508
+ console.warn(`[ichigo.js] Duplicate key detected in v-for: "${context.key}". All entries are still rendered, but reordering may be unstable. Keys should be unique.`);
10469
10509
  }
10470
- seenKeys.add(ctx.key);
10471
- neededKeys.add(ctx.key);
10510
+ seenKeys.add(context.key);
10511
+ const queue = pool.get(context.key);
10512
+ const reused = queue && queue.length ? queue.shift() : undefined;
10513
+ plan.push({ context, reused });
10472
10514
  }
10473
- // Remove items that are no longer needed
10515
+ // Remove rows that were not reused.
10474
10516
  // First destroy VNodes (calls @unmount hooks while DOM is still accessible)
10475
10517
  const nodesToRemove = [];
10476
- for (const [key, vNode] of this.#renderedItems) {
10477
- if (!neededKeys.has(key)) {
10518
+ for (const queue of pool.values()) {
10519
+ for (const vNode of queue) {
10478
10520
  nodesToRemove.push(vNode);
10479
10521
  vNode.destroy();
10480
10522
  }
@@ -10493,11 +10535,12 @@
10493
10535
  parentOfNode.removeChild(vNode.node);
10494
10536
  }
10495
10537
  }
10496
- // Add or reorder items
10538
+ // Add or reorder rows, building the new ordered rendered set.
10539
+ const newRenderedItems = [];
10497
10540
  let prevNode = anchor;
10498
- for (const context of newIterations) {
10541
+ for (const { context, reused } of plan) {
10499
10542
  const { key } = context;
10500
- let vNode = this.#renderedItems.get(key);
10543
+ let vNode = reused;
10501
10544
  if (!vNode) {
10502
10545
  // Create new item
10503
10546
  const clone = this.#cloneNode();
@@ -10529,7 +10572,7 @@
10529
10572
  if (clone.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
10530
10573
  const range = VFragmentRange.insert(parent, prevNode.nextSibling, 'vfor-fragment', clone);
10531
10574
  vNode.fragmentRange = range;
10532
- newRenderedItems.set(key, vNode);
10575
+ newRenderedItems.push({ key, vNode });
10533
10576
  vNode.forceUpdate();
10534
10577
  prevNode = range.lastNode;
10535
10578
  continue;
@@ -10543,12 +10586,12 @@
10543
10586
  else {
10544
10587
  parent.appendChild(nodeToInsert);
10545
10588
  }
10546
- newRenderedItems.set(key, vNode);
10589
+ newRenderedItems.push({ key, vNode });
10547
10590
  vNode.forceUpdate();
10548
10591
  }
10549
10592
  else {
10550
10593
  // Reuse existing item
10551
- newRenderedItems.set(key, vNode);
10594
+ newRenderedItems.push({ key, vNode });
10552
10595
  // Update bindings
10553
10596
  this.#updateItemBindings(vNode, context);
10554
10597
  // For fragment-backed iterations, move the entire range atomically.
@@ -10574,7 +10617,7 @@
10574
10617
  // Advance prevNode to this iteration's last DOM node
10575
10618
  prevNode = vNode.fragmentRange?.lastNode ?? vNode.anchorNode ?? vNode.node;
10576
10619
  }
10577
- // Update rendered items map
10620
+ // Update the ordered rendered set
10578
10621
  this.#renderedItems = newRenderedItems;
10579
10622
  }
10580
10623
  /**