@nanahoshi/mona-ui 0.24.0 → 0.24.2

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.
Files changed (35) hide show
  1. package/README.md +2 -0
  2. package/fesm2022/nanahoshi-mona-ui-button-group.mjs +1 -1
  3. package/fesm2022/nanahoshi-mona-ui-button.mjs +122 -60
  4. package/fesm2022/nanahoshi-mona-ui-calendar.mjs +1 -2
  5. package/fesm2022/nanahoshi-mona-ui-chip.mjs +1 -1
  6. package/fesm2022/nanahoshi-mona-ui-color-gradient.mjs +1 -1
  7. package/fesm2022/nanahoshi-mona-ui-color-picker.mjs +1 -1
  8. package/fesm2022/nanahoshi-mona-ui-common.mjs +265 -159
  9. package/fesm2022/nanahoshi-mona-ui-date-picker.mjs +1 -1
  10. package/fesm2022/nanahoshi-mona-ui-datetime-picker.mjs +1 -1
  11. package/fesm2022/nanahoshi-mona-ui-dialog.mjs +1 -1
  12. package/fesm2022/nanahoshi-mona-ui-dropdown-button.mjs +1 -1
  13. package/fesm2022/nanahoshi-mona-ui-editor.mjs +13 -13
  14. package/fesm2022/nanahoshi-mona-ui-filter.mjs +1 -1
  15. package/fesm2022/{nanahoshi-mona-ui-grid-grid-footer-row.component-CnPhVIr4.mjs → nanahoshi-mona-ui-grid-grid-footer-row.component-x_euoNnS.mjs} +2 -2
  16. package/fesm2022/{nanahoshi-mona-ui-grid-grid-list.component-DxY6bfTX.mjs → nanahoshi-mona-ui-grid-grid-list.component-C1_6vq77.mjs} +4 -4
  17. package/fesm2022/{nanahoshi-mona-ui-grid-grid-row.component-CaO5Lae6.mjs → nanahoshi-mona-ui-grid-grid-row.component-BhukVWIy.mjs} +3 -3
  18. package/fesm2022/{nanahoshi-mona-ui-grid-grid-virtual-list.component-jNYtqg5B.mjs → nanahoshi-mona-ui-grid-grid-virtual-list.component-XQKOPPVs.mjs} +4 -4
  19. package/fesm2022/{nanahoshi-mona-ui-grid-nanahoshi-mona-ui-grid-9FtfCutf.mjs → nanahoshi-mona-ui-grid-nanahoshi-mona-ui-grid-Iw-wvKGl.mjs} +13 -12
  20. package/fesm2022/nanahoshi-mona-ui-grid.mjs +1 -1
  21. package/fesm2022/nanahoshi-mona-ui-list-box.mjs +1 -1
  22. package/fesm2022/nanahoshi-mona-ui-notification.mjs +1 -1
  23. package/fesm2022/nanahoshi-mona-ui-numeric-text-box.mjs +2 -2
  24. package/fesm2022/nanahoshi-mona-ui-pager.mjs +1 -1
  25. package/fesm2022/nanahoshi-mona-ui-sheet.mjs +1 -1
  26. package/fesm2022/nanahoshi-mona-ui-split-button.mjs +1 -1
  27. package/fesm2022/nanahoshi-mona-ui-splitter.mjs +1 -1
  28. package/fesm2022/nanahoshi-mona-ui-tabs.mjs +1 -1
  29. package/fesm2022/nanahoshi-mona-ui-text-box.mjs +1 -1
  30. package/fesm2022/nanahoshi-mona-ui-time-picker.mjs +1 -1
  31. package/fesm2022/nanahoshi-mona-ui-time-selector.mjs +1 -1
  32. package/fesm2022/nanahoshi-mona-ui-window.mjs +1 -1
  33. package/package.json +1 -1
  34. package/types/nanahoshi-mona-ui-button.d.ts +44 -20
  35. package/types/nanahoshi-mona-ui-common.d.ts +116 -73
@@ -1,7 +1,6 @@
1
1
  import * as i0 from '@angular/core';
2
- import { Pipe, linkedSignal, computed, signal } from '@angular/core';
3
- import { any, contains, all, from, firstOrDefault, ImmutableDictionary } from '@mirei/ts-collections';
4
- import { DateTime } from 'luxon';
2
+ import { Pipe, linkedSignal, computed, signal, untracked } from '@angular/core';
3
+ import { any, contains, all, firstOrDefault, ImmutableDictionary } from '@mirei/ts-collections';
5
4
 
6
5
  class PreventableEvent {
7
6
  #event;
@@ -80,11 +79,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.6", ngImpor
80
79
  }] });
81
80
 
82
81
  class SlicePipe {
82
+ /**
83
+ * Returns the elements from `start` up to, but not including, `end`, with the semantics of `Array.prototype.slice`:
84
+ * negative indices count from the end, and an omitted `end` slices to the end. Returns an empty array for
85
+ * `null` or `undefined`, such as the first value of the `async` pipe.
86
+ */
83
87
  transform(value, start, end) {
84
- return from(value)
85
- .skip(start)
86
- .take(end - start)
87
- .toArray();
88
+ if (value == null) {
89
+ return [];
90
+ }
91
+ return Array.from(value).slice(start, end);
88
92
  }
89
93
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.6", ngImport: i0, type: SlicePipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
90
94
  static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "22.0.6", ngImport: i0, type: SlicePipe, isStandalone: true, name: "monaSlice" }); }
@@ -127,8 +131,14 @@ function deepMerge(base, patch, options = defaultOptions, path = []) {
127
131
  const out = isPlainObject(base) ? { ...base } : {};
128
132
  const keys = Object.keys(patch);
129
133
  for (const key of keys) {
134
+ if (key === "__proto__") {
135
+ continue;
136
+ }
130
137
  const patchValue = patch[key];
131
138
  if (patchValue === undefined) {
139
+ if (options.undefinedValues === "assign") {
140
+ out[key] = undefined;
141
+ }
132
142
  continue;
133
143
  }
134
144
  const baseValue = out[key];
@@ -146,28 +156,35 @@ function deepMerge(base, patch, options = defaultOptions, path = []) {
146
156
  ];
147
157
  }
148
158
  else {
149
- // unionBy Strategy
150
- const by = strategy.key;
151
- const map = new Map();
152
- const getIdentifier = (item) => {
153
- if (Array.isArray(by)) {
154
- return by.map(k => String(item?.[k] ?? "")).join("|");
159
+ // unionBy strategy: a patch item is merged into the base item with the same key; items without a
160
+ // key are kept as they are.
161
+ const identify = createUnionIdentifier(strategy.key);
162
+ const items = [];
163
+ const indexById = new Map();
164
+ const add = (item, merge) => {
165
+ const id = identify(item);
166
+ const index = id === undefined ? undefined : indexById.get(id);
167
+ if (index === undefined) {
168
+ if (id !== undefined) {
169
+ indexById.set(id, items.length);
170
+ }
171
+ items.push(item);
172
+ return;
155
173
  }
156
- return String(item?.[by] ?? "");
174
+ const existing = items[index];
175
+ items[index] = !merge
176
+ ? item
177
+ : isPlainObject(item) && isPlainObject(existing)
178
+ ? deepMerge(existing, item, options, currentPath)
179
+ : { ...existing, ...item };
157
180
  };
158
181
  for (const item of baseValue) {
159
- map.set(getIdentifier(item), item);
182
+ add(item, false);
160
183
  }
161
184
  for (const item of patchValue) {
162
- const id = getIdentifier(item);
163
- const existing = map.get(id);
164
- const mergedItem = isPlainObject(item) && isPlainObject(existing)
165
- ? // @ts-ignore // TODO: Remove this when it is no longer flagged as error
166
- deepMerge(existing, item, options)
167
- : { ...existing, ...item };
168
- map.set(id, mergedItem);
185
+ add(item, true);
169
186
  }
170
- out[key] = Array.from(map.values());
187
+ out[key] = items;
171
188
  }
172
189
  continue;
173
190
  }
@@ -185,23 +202,24 @@ function mergeInto(base, patch, options) {
185
202
  function mergePatchInto(base, patch, options) {
186
203
  return deepMerge(base, patch, options);
187
204
  }
188
- function buildNested(keys, leaf) {
189
- const [head, ...rest] = keys;
190
- if (head === undefined) {
205
+ /**
206
+ * Builds a patch that sets `leaf` at `keys`. When `current` (the value the patch will be merged into) holds an
207
+ * array along the path, the patch carries a copy of that array with the one element replaced, since arrays are
208
+ * replaced rather than merged. Without `current`, every level is an object.
209
+ */
210
+ function buildNested(keys, leaf, current) {
211
+ if (keys.length === 0) {
191
212
  return {};
192
213
  }
193
- if (rest.length === 0) {
194
- return { [head]: leaf };
195
- }
196
- return { [head]: buildNested(rest, leaf) };
214
+ return buildLevel(keys, leaf, current, false);
197
215
  }
198
- function patchAt(path, value) {
216
+ function patchAt(path, value, current) {
199
217
  const keys = path;
200
- const nested = buildNested(keys, value);
218
+ const nested = buildNested(keys, value, current);
201
219
  return nested;
202
220
  }
203
221
  function setAt(obj, path, value, options) {
204
- return deepMerge(obj, patchAt(path, value), options);
222
+ return deepMerge(obj, patchAt(path, value, obj), options);
205
223
  }
206
224
  function prunePatch(base, patch) {
207
225
  if (patch === null || typeof patch !== "object" || base === null || typeof base !== "object") {
@@ -211,7 +229,15 @@ function prunePatch(base, patch) {
211
229
  const patchObj = patch;
212
230
  const result = { ...patchObj };
213
231
  for (const key of Object.keys(patchObj)) {
232
+ if (key === "__proto__") {
233
+ delete result[key];
234
+ continue;
235
+ }
214
236
  if (!(key in baseObj)) {
237
+ // An undefined field the base doesn't have is no change.
238
+ if (patchObj[key] === undefined) {
239
+ delete result[key];
240
+ }
215
241
  continue;
216
242
  }
217
243
  const baseValue = baseObj[key];
@@ -231,39 +257,131 @@ function prunePatch(base, patch) {
231
257
  }
232
258
  return Object.keys(result).length === 0 ? undefined : result;
233
259
  }
260
+ /**
261
+ * Structural equality for plain objects, arrays, `Date`, `RegExp`, `Map` and `Set`. Primitives are equal when
262
+ * `===` says so, and `NaN` equals `NaN`. Dates are equal when they hold the same time (two invalid dates are
263
+ * equal). Map keys and set members are matched by `SameValueZero`, like `Map.prototype.has`. Cyclic values are
264
+ * supported.
265
+ */
234
266
  function deepEquals(o1, o2) {
235
- if (o1 === o2) {
267
+ return valuesEqual(o1, o2, new Map());
268
+ }
269
+ function valuesEqual(o1, o2, visited) {
270
+ if (o1 === o2 || (Number.isNaN(o1) && Number.isNaN(o2))) {
236
271
  return true;
237
272
  }
238
- if (typeof o1 !== typeof o2 || o1 === null || o2 === null || o1 === undefined || o2 === undefined) {
273
+ if (o1 === null || o2 === null || typeof o1 !== "object" || typeof o2 !== "object") {
239
274
  return false;
240
275
  }
241
- if (o1 instanceof Date && o2 instanceof Date) {
242
- return DateTime.fromJSDate(o1).equals(DateTime.fromJSDate(o2));
276
+ if (o1 instanceof Date || o2 instanceof Date) {
277
+ return o1 instanceof Date && o2 instanceof Date && Object.is(o1.getTime(), o2.getTime());
278
+ }
279
+ if (o1 instanceof RegExp || o2 instanceof RegExp) {
280
+ return o1 instanceof RegExp && o2 instanceof RegExp && o1.source === o2.source && o1.flags === o2.flags;
243
281
  }
244
- if (Array.isArray(o1) !== Array.isArray(o2)) {
282
+ // A pair already being compared further up is assumed equal; any difference shows up there.
283
+ let seen = visited.get(o1);
284
+ if (seen?.has(o2)) {
285
+ return true;
286
+ }
287
+ if (!seen) {
288
+ seen = new Set();
289
+ visited.set(o1, seen);
290
+ }
291
+ seen.add(o2);
292
+ if (o1 instanceof Map || o2 instanceof Map) {
293
+ return o1 instanceof Map && o2 instanceof Map && mapsEqual(o1, o2, visited);
294
+ }
295
+ if (o1 instanceof Set || o2 instanceof Set) {
296
+ return o1 instanceof Set && o2 instanceof Set && setsEqual(o1, o2);
297
+ }
298
+ if (Array.isArray(o1) || Array.isArray(o2)) {
299
+ return (Array.isArray(o1) &&
300
+ Array.isArray(o2) &&
301
+ o1.length === o2.length &&
302
+ o1.every((value, index) => valuesEqual(value, o2[index], visited)));
303
+ }
304
+ const keys1 = Object.keys(o1);
305
+ if (keys1.length !== Object.keys(o2).length) {
245
306
  return false;
246
307
  }
247
- if (Array.isArray(o1) && Array.isArray(o2)) {
248
- return o1.length === o2.length && o1.every((v, i) => deepEquals(v, o2[i]));
308
+ const record1 = o1;
309
+ const record2 = o2;
310
+ return keys1.every(key => Object.hasOwn(record2, key) && valuesEqual(record1[key], record2[key], visited));
311
+ }
312
+ /** Maps are equal when they have the same keys (by `SameValueZero`) and deeply equal values. */
313
+ function mapsEqual(m1, m2, visited) {
314
+ if (m1.size !== m2.size) {
315
+ return false;
249
316
  }
250
- if (typeof o1 === "object" && typeof o2 === "object") {
251
- const keys1 = Object.keys(o1);
252
- const keys2 = Object.keys(o2);
253
- if (keys1.length !== keys2.length) {
317
+ for (const [key, value] of m1) {
318
+ if (!m2.has(key) || !valuesEqual(value, m2.get(key), visited)) {
254
319
  return false;
255
320
  }
256
- for (const key of keys1) {
257
- const keyOfT = key;
258
- if (!Object.prototype.hasOwnProperty.call(o2, key) || !deepEquals(o1[keyOfT], o2[keyOfT])) {
259
- return false;
260
- }
321
+ }
322
+ return true;
323
+ }
324
+ /** Sets are equal when they have the same members (by `SameValueZero`). */
325
+ function setsEqual(s1, s2) {
326
+ if (s1.size !== s2.size) {
327
+ return false;
328
+ }
329
+ for (const value of s1) {
330
+ if (!s2.has(value)) {
331
+ return false;
261
332
  }
262
- return true;
263
333
  }
264
- return false;
334
+ return true;
335
+ }
336
+ /**
337
+ * One level of `buildNested`. Inside an array (`whole`), each level must be the whole value, since the array
338
+ * element replaces the old one; outside, a level holds only the key on the path.
339
+ */
340
+ function buildLevel(keys, leaf, current, whole) {
341
+ const [head, ...rest] = keys;
342
+ const isArray = Array.isArray(current);
343
+ const child = current != null && typeof current === "object"
344
+ ? current[head]
345
+ : undefined;
346
+ const value = rest.length === 0 ? leaf : buildLevel(rest, leaf, child, whole || isArray);
347
+ if (isArray) {
348
+ const copy = [...current];
349
+ copy[Number(head)] = value;
350
+ return copy;
351
+ }
352
+ if (whole && isPlainObject(current)) {
353
+ return { ...current, [head]: value };
354
+ }
355
+ return { [head]: value };
356
+ }
357
+ /**
358
+ * Returns a function that tells `unionBy` items apart by the values of their key fields, or returns `undefined` for
359
+ * an item that lacks one. Values keep their type (`1` and `"1"` differ), and composite keys can't collide.
360
+ */
361
+ function createUnionIdentifier(by) {
362
+ const keys = Array.isArray(by) ? by : [by];
363
+ return item => {
364
+ const values = keys.map(key => item != null && typeof item === "object" ? item[key] : undefined);
365
+ return values.some(value => value == null) ? undefined : JSON.stringify(values, unionKeyReplacer);
366
+ };
367
+ }
368
+ function unionKeyReplacer(_key, value) {
369
+ return typeof value === "bigint" ? { bigint: String(value) } : value;
265
370
  }
266
371
 
372
+ /**
373
+ * Drafts hold final values: an `undefined` in a patch clears the field, and the draft is applied over the base
374
+ * with `replace`, so an array strategy only ever applies once, to the patch that introduced it.
375
+ */
376
+ const STORE_MERGE_OPTIONS = { undefinedValues: "assign" };
377
+ /**
378
+ * Applies `patch` to the view of `base` with `draft`, and returns the fields that still differ from `base`.
379
+ */
380
+ function applyPatch(base, draft, patch, options) {
381
+ const currentView = deepMerge(base, draft, STORE_MERGE_OPTIONS);
382
+ const nextView = deepMerge(currentView, patch, { ...STORE_MERGE_OPTIONS, ...options });
383
+ return prunePatch(base, nextView) ?? {};
384
+ }
267
385
  class ObjectPatchStore {
268
386
  #base;
269
387
  #committed;
@@ -274,8 +392,8 @@ class ObjectPatchStore {
274
392
  computation: (newBase, previous) => {
275
393
  const strategy = this.#options.baseChangeStrategy ?? "wipe";
276
394
  if (!previous) {
277
- // First computation — initialise from options.initial if provided.
278
- return this.#options.initial?.() ?? {};
395
+ // First computation; the constructor then sets the initial draft.
396
+ return {};
279
397
  }
280
398
  switch (strategy) {
281
399
  case "preserve":
@@ -301,7 +419,7 @@ class ObjectPatchStore {
301
419
  return deepEquals(prunedDraft, prunedCommitted);
302
420
  }, /* @ts-ignore */
303
421
  ...(ngDevMode ? [{ debugName: "pristine" }] : /* istanbul ignore next */ []));
304
- this.view = computed(() => deepMerge(this.#base(), this.#draft()), /* @ts-ignore */
422
+ this.view = computed(() => deepMerge(this.#base(), this.#draft(), STORE_MERGE_OPTIONS), /* @ts-ignore */
305
423
  ...(ngDevMode ? [{ debugName: "view" }] : /* istanbul ignore next */ []));
306
424
  this.#options = options;
307
425
  this.#base = options.source();
@@ -325,32 +443,25 @@ class ObjectPatchStore {
325
443
  }
326
444
  patch(patch, options) {
327
445
  const effectiveOptions = options ?? this.#options.mergeOptions;
328
- this.#draft.update(currentDraft => {
329
- const currentFullView = deepMerge(this.#base(), currentDraft, effectiveOptions);
330
- const updatedFullView = deepMerge(currentFullView, patch, effectiveOptions);
331
- const nextDraft = prunePatch(this.#base(), updatedFullView);
332
- return nextDraft ?? {};
333
- });
446
+ this.#draft.update(currentDraft => applyPatch(this.#base(), currentDraft, patch, effectiveOptions));
334
447
  }
335
448
  patchPath(path, value, options) {
336
- const patch = buildNested(path, value);
449
+ // Arrays along the path are copied from the current view (see buildNested).
450
+ const patch = buildNested(path, value, untracked(this.view));
337
451
  this.patch(patch, options);
338
452
  }
339
453
  reset() {
340
454
  this.#draft.set({});
341
455
  this.#committed.set({});
342
456
  }
457
+ select(projector, equal = deepEquals) {
458
+ return computed(() => projector(this.view()), { equal });
459
+ }
343
460
  setDraft(patch) {
344
461
  this.#draft.set(patch);
345
462
  }
346
463
  setPath(path, value, options) {
347
- // Fix: delegate to patch() so the draft is merged rather than replaced,
348
- // preserving any unrelated in-progress changes.
349
- const patch = buildNested(path, value);
350
- this.patch(patch, options);
351
- }
352
- select(projector, equal = deepEquals) {
353
- return computed(() => projector(this.view()), { equal });
464
+ this.patchPath(path, value, options);
354
465
  }
355
466
  }
356
467
  class IterablePatchStore {
@@ -365,8 +476,9 @@ class IterablePatchStore {
365
476
  #options;
366
477
  /**
367
478
  * Memoisation cache for the `view` computed. Keyed by item id; each
368
- * entry stores the draft reference that produced the merged result so we
369
- * can skip deepMerge when neither the base item nor its draft changed.
479
+ * entry stores the base item and draft references that produced the merged
480
+ * result, so we can skip deepMerge when neither of them changed. Rebuilt on
481
+ * every computation, so ids that leave the base or lose their draft drop out.
370
482
  */
371
483
  #viewCache;
372
484
  constructor(options) {
@@ -376,8 +488,8 @@ class IterablePatchStore {
376
488
  */
377
489
  this.#baseMap = computed(() => new Map(Array.from(this.#base()).map(item => [this.#options.idOf(item), item])), /* @ts-ignore */
378
490
  ...(ngDevMode ? [{ debugName: "#baseMap" }] : /* istanbul ignore next */ []));
379
- this.#drafts = linkedSignal({ ...(ngDevMode ? { debugName: "#drafts" } : /* istanbul ignore next */ {}), source: () => this.#options.source(),
380
- computation: (newSource, previous) => {
491
+ this.#drafts = linkedSignal({ ...(ngDevMode ? { debugName: "#drafts" } : /* istanbul ignore next */ {}), source: () => this.#base(),
492
+ computation: (newBase, previous) => {
381
493
  const strategy = this.#options.baseChangeStrategy ?? "wipe";
382
494
  if (!previous) {
383
495
  return ImmutableDictionary.create();
@@ -386,10 +498,10 @@ class IterablePatchStore {
386
498
  case "preserve":
387
499
  return previous.value;
388
500
  case "rebase": {
389
- const baseArray = Array.from(newSource());
501
+ const baseMap = new Map(Array.from(newBase, item => [this.#options.idOf(item), item]));
390
502
  let rebased = ImmutableDictionary.create();
391
503
  for (const [id, draft] of previous.value.entries()) {
392
- const baseItem = baseArray.find(item => this.#options.idOf(item) === id);
504
+ const baseItem = baseMap.get(id);
393
505
  if (!baseItem) {
394
506
  // Item no longer exists in base — drop the draft.
395
507
  continue;
@@ -408,30 +520,32 @@ class IterablePatchStore {
408
520
  } });
409
521
  /**
410
522
  * Memoisation cache for the `view` computed. Keyed by item id; each
411
- * entry stores the draft reference that produced the merged result so we
412
- * can skip deepMerge when neither the base item nor its draft changed.
523
+ * entry stores the base item and draft references that produced the merged
524
+ * result, so we can skip deepMerge when neither of them changed. Rebuilt on
525
+ * every computation, so ids that leave the base or lose their draft drop out.
413
526
  */
414
527
  this.#viewCache = new Map();
415
528
  this.changes = computed(() => {
416
- const baseArray = Array.from(this.#base());
417
529
  const drafts = this.#drafts();
418
- return baseArray
419
- .filter(b => {
530
+ const changes = [];
531
+ for (const b of this.#base()) {
420
532
  const draft = drafts.get(this.#options.idOf(b));
421
- return draft != null && !isEmptyObject(draft);
422
- })
423
- .map(b => deepMerge(b, drafts.get(this.#options.idOf(b))));
533
+ if (draft != null && !isEmptyObject(draft)) {
534
+ changes.push(deepMerge(b, draft, STORE_MERGE_OPTIONS));
535
+ }
536
+ }
537
+ return changes;
424
538
  }, /* @ts-ignore */
425
539
  ...(ngDevMode ? [{ debugName: "changes" }] : /* istanbul ignore next */ []));
426
540
  this.pristine = computed(() => {
427
- const baseArray = Array.from(this.#base());
541
+ const baseMap = this.#baseMap();
428
542
  const drafts = this.#drafts();
429
543
  const committed = this.#committed();
430
544
  const allIds = new Set([...drafts.keys(), ...committed.keys()]);
431
545
  for (const id of allIds) {
432
546
  const draft = drafts.get(id) ?? {};
433
547
  const committedDraft = committed.get(id) ?? {};
434
- const originalItem = baseArray.find(item => this.#options.idOf(item) === id);
548
+ const originalItem = baseMap.get(id);
435
549
  const prunedDraft = originalItem ? (prunePatch(originalItem, draft) ?? {}) : draft;
436
550
  const prunedCommitted = originalItem
437
551
  ? (prunePatch(originalItem, committedDraft) ?? {})
@@ -445,52 +559,45 @@ class IterablePatchStore {
445
559
  ...(ngDevMode ? [{ debugName: "pristine" }] : /* istanbul ignore next */ []));
446
560
  this.view = computed(() => {
447
561
  const drafts = this.#drafts();
448
- return Array.from(this.#base()).map(b => {
562
+ const previousCache = this.#viewCache;
563
+ const nextCache = new Map();
564
+ const view = Array.from(this.#base()).map(b => {
449
565
  const id = this.#options.idOf(b);
450
566
  const patch = drafts.get(id);
451
567
  if (!patch || isEmptyObject(patch)) {
452
- // No draft — return base item directly and evict any stale cache entry.
453
- this.#viewCache.delete(id);
568
+ // No draft — return base item directly.
454
569
  return b;
455
570
  }
456
- const cached = this.#viewCache.get(id);
457
- if (cached && cached.patchRef === patch) {
458
- // Draft reference hasn't changed — return cached merged value
459
- // to avoid an unnecessary deepMerge and a new object allocation.
460
- return cached.merged;
461
- }
462
- const merged = deepMerge(b, patch);
463
- this.#viewCache.set(id, { patchRef: patch, merged });
571
+ const cached = previousCache.get(id);
572
+ // Reuse the merged value only when neither the base item nor its
573
+ // draft changed, to avoid an unnecessary deepMerge and allocation.
574
+ const merged = cached && cached.baseRef === b && cached.patchRef === patch
575
+ ? cached.merged
576
+ : deepMerge(b, patch, STORE_MERGE_OPTIONS);
577
+ nextCache.set(id, { baseRef: b, merged, patchRef: patch });
464
578
  return merged;
465
579
  });
580
+ this.#viewCache = nextCache;
581
+ return view;
466
582
  }, /* @ts-ignore */
467
583
  ...(ngDevMode ? [{ debugName: "view" }] : /* istanbul ignore next */ []));
468
584
  this.#options = options;
469
585
  this.#base = options.source();
470
- let initial;
471
- if (options.initial) {
472
- if (options.initial() instanceof ImmutableDictionary) {
473
- initial = options.initial();
474
- }
475
- else {
476
- initial = ImmutableDictionary.create(options.initial());
477
- }
478
- }
479
- else {
480
- initial = ImmutableDictionary.create();
481
- }
586
+ const initialValue = options.initial?.();
587
+ const initial = initialValue instanceof ImmutableDictionary
588
+ ? initialValue
589
+ : ImmutableDictionary.create(initialValue ?? []);
482
590
  this.#committed = signal(initial, /* @ts-ignore */
483
591
  ...(ngDevMode ? [{ debugName: "#committed" }] : /* istanbul ignore next */ []));
484
- if (options.initial) {
485
- this.#drafts.set(ImmutableDictionary.create(initial));
592
+ if (initialValue) {
593
+ this.#drafts.set(initial);
486
594
  }
487
595
  }
488
596
  clear(id) {
489
597
  this.#drafts.update(drafts => {
490
598
  if (!drafts.containsKey(id))
491
599
  return drafts;
492
- const newDrafts = ImmutableDictionary.create(drafts);
493
- return newDrafts.remove(id);
600
+ return drafts.remove(id);
494
601
  });
495
602
  }
496
603
  clearAll() {
@@ -515,50 +622,47 @@ class IterablePatchStore {
515
622
  const patch = this.#drafts().get(id);
516
623
  return patch != null && !isEmptyObject(patch);
517
624
  }
518
- patch(id, patch, options) {
625
+ patch(id, patch, options = this.#options.mergeOptions) {
519
626
  this.#drafts.update(drafts => {
520
- let newDrafts = ImmutableDictionary.create(drafts);
521
627
  const prev = drafts.get(id) ?? {};
522
- let next = deepMerge(prev, patch, options);
523
- // Fix: use #baseMap for O(1) lookup instead of a linear scan.
524
628
  const originalItem = this.#baseMap().get(id);
525
- if (originalItem) {
526
- next = prunePatch(originalItem, next) ?? {};
527
- }
528
- if (isEmptyObject(next)) {
529
- newDrafts = newDrafts.remove(id);
530
- }
531
- else {
532
- newDrafts = newDrafts.put(id, next);
533
- }
534
- return newDrafts;
629
+ const next = originalItem
630
+ ? applyPatch(originalItem, prev, patch, options)
631
+ : deepMerge(prev, patch, { ...STORE_MERGE_OPTIONS, ...options });
632
+ return isEmptyObject(next) ? drafts.remove(id) : drafts.put(id, next);
535
633
  });
536
634
  }
537
- patchMany(patchTuple, options) {
538
- // Fix: prune each resulting draft against the base item for consistency
539
- // with patch(), and use #baseMap for O(1) lookups.
635
+ patchMany(patchTuple, options = this.#options.mergeOptions) {
540
636
  const baseMap = this.#baseMap();
541
637
  this.#drafts.update(drafts => {
542
- let newDrafts = ImmutableDictionary.create(drafts);
638
+ // Each put on an ImmutableDictionary copies it, so collect the drafts in a Map and build one dictionary.
639
+ const nextDrafts = new Map(drafts.entries());
543
640
  for (const [id, value] of patchTuple) {
544
- const prev = newDrafts.get(id) ?? {};
545
- let next = deepMerge(prev, value, options);
641
+ const prev = nextDrafts.get(id) ?? {};
546
642
  const originalItem = baseMap.get(id);
547
- if (originalItem) {
548
- next = prunePatch(originalItem, next) ?? {};
549
- }
643
+ const next = originalItem
644
+ ? applyPatch(originalItem, prev, value, options)
645
+ : deepMerge(prev, value, { ...STORE_MERGE_OPTIONS, ...options });
550
646
  if (isEmptyObject(next)) {
551
- newDrafts = newDrafts.remove(id);
647
+ nextDrafts.delete(id);
552
648
  }
553
649
  else {
554
- newDrafts = newDrafts.put(id, next);
650
+ nextDrafts.set(id, next);
555
651
  }
556
652
  }
557
- return newDrafts;
653
+ return ImmutableDictionary.create(nextDrafts);
558
654
  });
559
655
  }
560
656
  patchPath(id, path, value, options) {
561
- const patch = buildNested(path, value);
657
+ // Arrays along the path are copied from the item's current view (see buildNested).
658
+ const current = untracked(() => {
659
+ const baseItem = this.#baseMap().get(id);
660
+ const draft = this.#drafts().get(id);
661
+ return baseItem !== undefined && draft
662
+ ? deepMerge(baseItem, draft, STORE_MERGE_OPTIONS)
663
+ : (baseItem ?? draft);
664
+ });
665
+ const patch = buildNested(path, value, current);
562
666
  this.patch(id, patch, options);
563
667
  }
564
668
  reset() {
@@ -566,21 +670,14 @@ class IterablePatchStore {
566
670
  this.#drafts.set(empty);
567
671
  this.#committed.set(empty);
568
672
  }
673
+ select(projector, equal = deepEquals) {
674
+ return computed(() => projector(this.view()), { equal });
675
+ }
569
676
  setDraft(id, patch) {
570
- this.#drafts.update(drafts => {
571
- let newDrafts = ImmutableDictionary.create(drafts);
572
- newDrafts = newDrafts.put(id, patch);
573
- return newDrafts;
574
- });
677
+ this.#drafts.update(drafts => isEmptyObject(patch) ? drafts.remove(id) : drafts.put(id, patch));
575
678
  }
576
679
  setPath(id, path, value, options) {
577
- // Fix: delegate to patch() so the draft is merged rather than replaced,
578
- // preserving any unrelated in-progress changes.
579
- const patch = buildNested(path, value);
580
- this.patch(id, patch, options);
581
- }
582
- select(projector, equal = deepEquals) {
583
- return computed(() => projector(this.view()), { equal });
680
+ this.patchPath(id, path, value, options);
584
681
  }
585
682
  }
586
683
  const createIterablePatchStore = (options) => new IterablePatchStore(options);
@@ -594,11 +691,12 @@ const classInputToClass = (input) => {
594
691
  return input;
595
692
  }
596
693
  if (Array.isArray(input)) {
597
- return input.join(" ");
694
+ return input.filter(Boolean).join(" ");
598
695
  }
599
696
  if (typeof input === "object") {
600
697
  return Object.entries(input)
601
- .map(([key, value]) => (!!value ? key : ""))
698
+ .filter(([, value]) => value)
699
+ .map(([key]) => key)
602
700
  .join(" ");
603
701
  }
604
702
  return "";
@@ -609,24 +707,32 @@ const classInputToClass = (input) => {
609
707
  * @param value The current value.
610
708
  * @param min The minimum value.
611
709
  * @param max The maximum value.
612
- * @param precision The number of decimal places to round the percentage to.
710
+ * @param precision The number of decimal places to round the percentage to, clamped to 0–100.
613
711
  * @default 2
614
- * @returns The percentage of the value within the range, rounded to the specified precision.
712
+ * @returns The percentage of the value within the range, rounded to the specified precision, or 0 when the range
713
+ * or the value is not finite.
615
714
  */
616
715
  const getPercentage = (value, min, max, precision = 2) => {
617
- if (max <= min || !isFinite(value)) {
716
+ if (!isFinite(value) || !isFinite(min) || !isFinite(max) || max <= min) {
618
717
  return 0;
619
718
  }
620
719
  const raw = ((value - min) / (max - min)) * 100;
621
- return Number(Math.min(100, Math.max(0, raw)).toFixed(precision));
720
+ const digits = Math.min(100, Math.max(0, Math.trunc(precision) || 0));
721
+ return Number(Math.min(100, Math.max(0, raw)).toFixed(digits));
622
722
  };
623
723
 
724
+ /**
725
+ * Moves the items at `indices` by `offset` positions, keeping their relative order. Returns a copy of the array
726
+ * unchanged when the offset is 0 or not an integer, or when any index is not an integer, is out of bounds, or would
727
+ * move out of bounds. Duplicate indices count once.
728
+ */
624
729
  function moveIndices(array, indices, offset) {
625
- if (offset === 0 || indices.length === 0) {
730
+ if (offset === 0 || indices.length === 0 || !Number.isInteger(offset)) {
626
731
  return [...array];
627
732
  }
628
- // Validate indices are within bounds
629
- if (indices.some(i => i < 0 || i >= array.length)) {
733
+ indices = [...new Set(indices)];
734
+ // Validate indices are integers within bounds
735
+ if (indices.some(i => !Number.isInteger(i) || i < 0 || i >= array.length)) {
630
736
  return [...array];
631
737
  }
632
738
  // Check if any index would go out of bounds after the move