@shortfuse/materialdesignweb 0.9.3 → 0.10.0

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 (53) hide show
  1. package/README.md +51 -37
  2. package/api/README.md +5 -0
  3. package/api/css.css-data.json +23 -0
  4. package/api/custom-elements.json +116343 -0
  5. package/api/html.html-data.json +2994 -0
  6. package/bin/mdw-css.js +8 -8
  7. package/components/BottomSheet.js +2 -2
  8. package/components/Box.js +2 -2
  9. package/components/NavItem.js +4 -1
  10. package/components/Page.js +3 -0
  11. package/components/Pane.js +0 -2
  12. package/components/SideSheet.js +2 -2
  13. package/components/Surface.js +2 -2
  14. package/core/Composition.js +57 -16
  15. package/core/CompositionAdapter.js +55 -12
  16. package/core/CustomElement.js +177 -34
  17. package/core/customTypes.js +66 -0
  18. package/core/jsonMergePatch.js +203 -20
  19. package/core/observe.js +256 -10
  20. package/dist/CustomElement.min.js +1 -1
  21. package/dist/CustomElement.min.js.map +3 -3
  22. package/dist/index.min.js +171 -158
  23. package/dist/index.min.js.map +4 -4
  24. package/dist/meta.json +1 -1
  25. package/loaders/theme.js +4 -1
  26. package/mixins/{FlexableMixin.js → FlexboxMixin.js} +5 -3
  27. package/package.json +32 -10
  28. package/services/theme.js +115 -97
  29. package/types/components/NavBarItem.d.ts +1 -0
  30. package/types/components/NavDrawerItem.d.ts +1 -0
  31. package/types/components/NavItem.d.ts +1 -0
  32. package/types/components/NavItem.d.ts.map +1 -1
  33. package/types/components/NavRailItem.d.ts +1 -0
  34. package/types/components/Page.d.ts.map +1 -1
  35. package/types/core/Composition.d.ts +5 -1
  36. package/types/core/Composition.d.ts.map +1 -1
  37. package/types/core/CompositionAdapter.d.ts +0 -25
  38. package/types/core/CompositionAdapter.d.ts.map +1 -1
  39. package/types/core/CustomElement.d.ts +122 -19
  40. package/types/core/CustomElement.d.ts.map +1 -1
  41. package/types/core/customTypes.d.ts +8 -0
  42. package/types/core/customTypes.d.ts.map +1 -1
  43. package/types/core/jsonMergePatch.d.ts +58 -4
  44. package/types/core/jsonMergePatch.d.ts.map +1 -1
  45. package/types/core/observe.d.ts +21 -2
  46. package/types/core/observe.d.ts.map +1 -1
  47. package/types/index.d.ts +1 -1
  48. package/types/mixins/FlexboxMixin.d.ts +14 -0
  49. package/types/mixins/FlexboxMixin.d.ts.map +1 -0
  50. package/types/services/theme.d.ts +6 -0
  51. package/types/services/theme.d.ts.map +1 -1
  52. package/types/mixins/FlexableMixin.d.ts +0 -14
  53. package/types/mixins/FlexableMixin.d.ts.map +0 -1
@@ -113,16 +113,6 @@ import { addInlineFunction, html } from './template.js';
113
113
  * @typedef {(T | Array<[keyof T & string, T[keyof T]]>)} ObjectOrObjectEntries
114
114
  */
115
115
 
116
- /**
117
- * @template {abstract new (...args: any) => unknown} T
118
- * @param {InstanceType<T>} instance
119
- */
120
- function superOf(instance) {
121
- const staticContext = instance.constructor;
122
- const superOfStatic = Object.getPrototypeOf(staticContext);
123
- return superOfStatic.prototype;
124
- }
125
-
126
116
  /**
127
117
  * Clone attribute
128
118
  * @param {string} name
@@ -191,13 +181,8 @@ export default class CustomElement extends HTMLElement {
191
181
  static supportsElementInternalsRole = CustomElement.supportsElementInternals
192
182
  && 'role' in ElementInternals.prototype;
193
183
 
194
- /** @type {boolean} */
195
- static templatable = null;
196
-
197
184
  static defined = false;
198
185
 
199
- static autoRegistration = true;
200
-
201
186
  /** @type {Map<string, typeof CustomElement>} */
202
187
  static registrations = new Map();
203
188
 
@@ -326,6 +311,42 @@ export default class CustomElement extends HTMLElement {
326
311
  this[collection].push(callback);
327
312
  }
328
313
 
314
+ /**
315
+ * @this T
316
+ * @template {typeof CustomElement} T
317
+ * @template {keyof T} K
318
+ * @param {K} collection
319
+ * @param {Function} [callback]
320
+ */
321
+ static _removeCallback(collection, callback) {
322
+ if (!this.hasOwnProperty(collection)) {
323
+ // @ts-expect-error not typed
324
+ this[collection] = [...this[collection]];
325
+ }
326
+ // @ts-expect-error any
327
+ this[collection] = this[collection].filter((fn) => fn !== callback);
328
+ }
329
+
330
+ /**
331
+ * @param {Map<string, Function[]>} map
332
+ * @param {string} name
333
+ * @param {Function} [callback]
334
+ * @return {void}
335
+ */
336
+ static _removeFromCallbackMap(map, name, callback) {
337
+ if (!map.has(name)) return;
338
+ if (!callback) {
339
+ map.delete(name);
340
+ return;
341
+ }
342
+ const next = map.get(name).filter((fn) => fn !== callback);
343
+ if (next.length === 0) {
344
+ map.delete(name);
345
+ } else {
346
+ map.set(name, next);
347
+ }
348
+ }
349
+
329
350
  /**
330
351
  * Append parts to composition
331
352
  * @type {{
@@ -627,7 +648,7 @@ export default class CustomElement extends HTMLElement {
627
648
  }
628
649
  // TODO: Inspect possible closure bloat
629
650
  config.changedCallback = function wrappedChangedCallback(oldValue, newValue, changes) {
630
- this._onObserverPropertyChanged.call(this, name, oldValue, newValue, changes);
651
+ this.#onObserverPropertyChanged.call(this, name, oldValue, newValue, changes);
631
652
  };
632
653
 
633
654
  this.propList.set(name, config);
@@ -644,6 +665,32 @@ export default class CustomElement extends HTMLElement {
644
665
  return this;
645
666
  }
646
667
 
668
+ /**
669
+ * Creates observable property on instances (via prototype)
670
+ * @type {{
671
+ * <
672
+ * CLASS extends typeof CustomElement,
673
+ * ARGS extends ConstructorParameters<CLASS>,
674
+ * INSTANCE extends InstanceType<CLASS>,
675
+ * KEY extends string,
676
+ * OPTIONS extends ObserverPropertyType
677
+ * | ObserverOptions<ObserverPropertyType, unknown, INSTANCE>
678
+ * | ((this:INSTANCE, data:Partial<INSTANCE>, fn?: () => any) => any)
679
+ * > (this: CLASS, name: KEY, options: OPTIONS)
680
+ * : OPTIONS extends (...args2:any[]) => infer R ? R
681
+ * : OPTIONS extends ObserverPropertyType ? import('./observe').ParsedObserverPropertyType<OPTIONS>
682
+ * : OPTIONS extends {type: 'object'} & ObserverOptions<any, infer R> ? (unknown extends R ? object : R)
683
+ * : OPTIONS extends {type: ObserverPropertyType} ? import('./observe').ParsedObserverPropertyType<OPTIONS['type']>
684
+ * : OPTIONS extends ObserverOptions<any, infer R> ? (unknown extends R ? string : R)
685
+ * : never
686
+ * }}
687
+ */
688
+ static setPrototype(name, typeOrOptions) {
689
+ // @ts-expect-error Can't cast T
690
+ this.prop(name, typeOrOptions);
691
+ return null;
692
+ }
693
+
647
694
  /**
648
695
  * Define properties on instances via Object.defineProperties().
649
696
  * Automatically sets property non-enumerable if name begins with `_`.
@@ -924,6 +971,109 @@ export default class CustomElement extends HTMLElement {
924
971
  return this;
925
972
  }
926
973
 
974
+ /**
975
+ * @type {{
976
+ * <
977
+ * T1 extends typeof CustomElement,
978
+ * T2 extends InstanceType<T1>,
979
+ * T3 extends CompositionCallback<T2, T2>,
980
+ * T4 extends keyof T3,
981
+ * >
982
+ * (this: T1, name: T3|T4, callbacks?: T3[T4] & ThisType<T2>): T1
983
+ * }}
984
+ */
985
+ static off(nameOrCallbacks, callback) {
986
+ const callbacks = typeof nameOrCallbacks === 'string'
987
+ ? { [nameOrCallbacks]: callback }
988
+ : nameOrCallbacks;
989
+ for (const [name, fn] of Object.entries(callbacks)) {
990
+ /** @type {keyof (typeof CustomElement)} */
991
+ let arrayPropName;
992
+ switch (name) {
993
+ case 'composed': arrayPropName = '_onComposeCallbacks'; break;
994
+ case 'constructed': arrayPropName = '_onConstructedCallbacks'; break;
995
+ case 'connected': arrayPropName = '_onConnectedCallbacks'; break;
996
+ case 'disconnected': arrayPropName = '_onDisconnectedCallbacks'; break;
997
+ case 'props':
998
+ this.offPropChanged(fn);
999
+ continue;
1000
+ case 'attrs':
1001
+ this.offAttributeChanged(fn);
1002
+ continue;
1003
+ default:
1004
+ if (name.endsWith('Changed')) {
1005
+ const prop = name.slice(0, name.length - 'Changed'.length);
1006
+ // @ts-expect-error Computed key
1007
+ this.offPropChanged({ [prop]: fn });
1008
+ continue;
1009
+ }
1010
+ throw new Error('Invalid callback name');
1011
+ }
1012
+ this._removeCallback(arrayPropName, fn);
1013
+ }
1014
+
1015
+ return this;
1016
+ }
1017
+
1018
+ /**
1019
+ * @type {{
1020
+ * <
1021
+ * T1 extends typeof CustomElement,
1022
+ * T2 extends InstanceType<T1>
1023
+ * >
1024
+ * (
1025
+ * this: T1,
1026
+ * options: ObjectOrObjectEntries<{
1027
+ * [P in keyof T2]? : (
1028
+ * this: T2,
1029
+ * oldValue: T2[P],
1030
+ * newValue: T2[P],
1031
+ * changes:any,
1032
+ * element: T2
1033
+ * ) => void
1034
+ * }>,
1035
+ * ): T1;
1036
+ * }}
1037
+ */
1038
+ static offPropChanged(options) {
1039
+ const entries = Array.isArray(options) ? options : Object.entries(options);
1040
+ const { propChangedCallbacks } = this;
1041
+ for (const [prop, callback] of entries) {
1042
+ this._removeFromCallbackMap(propChangedCallbacks, prop, callback);
1043
+ }
1044
+
1045
+ return this;
1046
+ }
1047
+
1048
+ /**
1049
+ * @type {{
1050
+ * <
1051
+ * T1 extends typeof CustomElement,
1052
+ * T2 extends InstanceType<T1>
1053
+ * >
1054
+ * (
1055
+ * this: T1,
1056
+ * options: {
1057
+ * [x:string]: (
1058
+ * this: T2,
1059
+ * oldValue: string,
1060
+ * newValue: string,
1061
+ * element: T2
1062
+ * ) => void
1063
+ * },
1064
+ * ): T1;
1065
+ * }}
1066
+ */
1067
+ static offAttributeChanged(options) {
1068
+ const entries = Array.isArray(options) ? options : Object.entries(options);
1069
+ const { attributeChangedCallbacks } = this;
1070
+ for (const [name, callback] of entries) {
1071
+ this._removeFromCallbackMap(attributeChangedCallbacks, name, callback);
1072
+ }
1073
+
1074
+ return this;
1075
+ }
1076
+
927
1077
  /**
928
1078
  * @type {{
929
1079
  * <
@@ -1009,10 +1159,10 @@ export default class CustomElement extends HTMLElement {
1009
1159
  #pendingPatchRenders = [];
1010
1160
 
1011
1161
  /** @type {Map<string,{stringValue:string, parsedValue:any}>} */
1012
- _propAttributeCache;
1162
+ #propAttributeCache;
1013
1163
 
1014
1164
  /** @type {CallbackArguments} */
1015
- _callbackArguments = null;
1165
+ #callbackArguments = null;
1016
1166
 
1017
1167
  /** @param {any[]} args */
1018
1168
  constructor(...args) {
@@ -1141,7 +1291,7 @@ export default class CustomElement extends HTMLElement {
1141
1291
  * @param {any} newValue
1142
1292
  * @param {any} changes
1143
1293
  */
1144
- _onObserverPropertyChanged(name, oldValue, newValue, changes) {
1294
+ #onObserverPropertyChanged(name, oldValue, newValue, changes) {
1145
1295
  const { propList } = this.static;
1146
1296
  if (propList.has(name)) {
1147
1297
  const { reflect, attr } = propList.get(name);
@@ -1177,10 +1327,12 @@ export default class CustomElement extends HTMLElement {
1177
1327
  this.propChangedCallback(name, oldValue, newValue, changes);
1178
1328
  }
1179
1329
 
1330
+ get static() { return /** @type {typeof CustomElement} */ (/** @type {unknown} */ (this.constructor)); }
1331
+
1180
1332
  /** @param {any} patch */
1181
1333
  patch(patch) {
1182
1334
  this.#patching = true;
1183
- applyMergePatch(this, patch);
1335
+ applyMergePatch(this, patch, 'object');
1184
1336
  for (const [name, changes, state] of this.#pendingPatchRenders) {
1185
1337
  if (name in patch) continue;
1186
1338
  this.render.byProp(name, changes, state);
@@ -1204,9 +1356,6 @@ export default class CustomElement extends HTMLElement {
1204
1356
  * @return {Element}
1205
1357
  */
1206
1358
  get: (target, tag) => {
1207
- if (!this.#composition) {
1208
- console.warn(this.static.name, 'Attempted to access references before composing!');
1209
- }
1210
1359
  const composition = this.composition;
1211
1360
  let element;
1212
1361
  if (!composition.interpolated) {
@@ -1241,20 +1390,16 @@ export default class CustomElement extends HTMLElement {
1241
1390
 
1242
1391
  get attributeCache() {
1243
1392
  // eslint-disable-next-line no-return-assign
1244
- return (this._propAttributeCache ??= new Map());
1393
+ return (this.#propAttributeCache ??= new Map());
1245
1394
  }
1246
1395
 
1247
- get static() { return /** @type {typeof CustomElement} */ (/** @type {unknown} */ (this.constructor)); }
1248
-
1249
- get unique() { return false; }
1250
-
1251
1396
  /**
1252
1397
  * @template {CustomElement} T
1253
1398
  * @this {T}
1254
1399
  */
1255
1400
  get callbackArguments() {
1256
1401
  // eslint-disable-next-line no-return-assign
1257
- return this._callbackArguments ??= {
1402
+ return this.#callbackArguments ??= {
1258
1403
  composition: this.#composition,
1259
1404
  refs: this.refs,
1260
1405
  html: html.bind(this),
@@ -1268,7 +1413,7 @@ export default class CustomElement extends HTMLElement {
1268
1413
  get composition() {
1269
1414
  if (this.#composition) return this.#composition;
1270
1415
 
1271
- if (!this.unique && this.static.hasOwnProperty('_composition')) {
1416
+ if (this.static.hasOwnProperty('_composition')) {
1272
1417
  this.#composition = this.static._composition;
1273
1418
  return this.static._composition;
1274
1419
  }
@@ -1281,10 +1426,8 @@ export default class CustomElement extends HTMLElement {
1281
1426
  callback.call(this, this.callbackArguments);
1282
1427
  }
1283
1428
 
1284
- if (!this.unique) {
1285
- // Cache compilation into static property
1286
- this.static._composition = this.#composition;
1287
- }
1429
+ // Cache compilation into static property
1430
+ this.static._composition = this.#composition;
1288
1431
 
1289
1432
  return this.#composition;
1290
1433
  }
@@ -1,6 +1,7 @@
1
1
  /** @typedef {import('./CustomElement').default} CustomElement */
2
2
 
3
3
  import { attrNameFromPropName } from './dom.js';
4
+ import { subscribeProxy } from './observe.js';
4
5
 
5
6
  /**
6
7
  * @see https://html.spec.whatwg.org/multipage/webappapis.html#event-handler-attributes
@@ -53,6 +54,71 @@ export const EVENT_HANDLER_TYPE = {
53
54
  },
54
55
  };
55
56
 
57
+ /**
58
+ * @template {unknown} T
59
+ * @typedef {import('./observe.js').ObserverOptions<'object', T, CustomElement>} STORE_PROXY_TYPE
60
+ */
61
+
62
+ /** @typedef {WeakMap<CustomElement, Map<string, Function>>} */
63
+ const StoreProxySubscriptions = new WeakMap();
64
+
65
+ /**
66
+ * Proxy store binding. Expects a proxy with a `.subscribe(fn)` method that
67
+ * returns an unsubscribe function.
68
+ * @template {unknown} T
69
+ * @type {import('./observe.js').ObserverOptions<'object', T, CustomElement>}
70
+ */
71
+ export const STORE_PROXY_TYPE = {
72
+ type: 'object',
73
+ reflect: false,
74
+ parser(v) { return v; },
75
+ diff: null, // Skip computing entire change
76
+ propChangedCallback(name, oldValue, newValue) {
77
+ if (oldValue === newValue) return;
78
+ /** @return {void} */
79
+ function connectedCallback() {
80
+ let subscriptions = StoreProxySubscriptions.get(this);
81
+ if (!subscriptions) {
82
+ subscriptions = new Map();
83
+ StoreProxySubscriptions.set(this, subscriptions);
84
+ }
85
+ const unsubscribeFn = subscribeProxy(this[name], (patch) => {
86
+ this.render.byProp(name, patch, this);
87
+ });
88
+ subscriptions.set(name, unsubscribeFn);
89
+ // Render immediately
90
+ this.render.byProp(name, this[name], this);
91
+ }
92
+ /** @return {void} */
93
+ function disconnectedCallback() {
94
+ const subscriptions = StoreProxySubscriptions.get(this);
95
+ if (!subscriptions?.has(name)) return;
96
+ const unsubscribeFn = subscriptions.get(name);
97
+ unsubscribeFn();
98
+ subscriptions.delete(name);
99
+ }
100
+ if (oldValue) {
101
+ // Perform unsubscribe
102
+ disconnectedCallback.call(this);
103
+ }
104
+
105
+ if (newValue) {
106
+ this.static.on({
107
+ connected: connectedCallback,
108
+ disconnected: disconnectedCallback,
109
+ });
110
+ if (this.isConnected) {
111
+ connectedCallback.call(this);
112
+ }
113
+ } else {
114
+ this.static.off({
115
+ connected: connectedCallback,
116
+ disconnected: disconnectedCallback,
117
+ });
118
+ }
119
+ },
120
+ };
121
+
56
122
  // const weakRefValues = new WeakMap();
57
123
 
58
124
  // /**
@@ -1,15 +1,17 @@
1
- /** @link https://www.rfc-editor.org/rfc/rfc7396 */
1
+ /** @see https://www.rfc-editor.org/rfc/rfc7396 */
2
2
 
3
3
  /**
4
+ * Default behavior: arrays are treated like objects (index/length merge).
4
5
  * @template T1
5
6
  * @template T2
6
7
  * @param {T1} target
7
8
  * @param {T2} patch
8
9
  * @return {T1|T2|(T1 & T2)}
9
10
  */
10
- export function applyMergePatch(target, patch) {
11
+ export function applyMergePatchObject(target, patch) {
11
12
  // @ts-ignore Runtime check
12
13
  if (target === patch) return target;
14
+ if (Array.isArray(patch)) return patch;
13
15
  if (target == null || patch == null || typeof patch !== 'object') return patch;
14
16
  if (typeof target !== 'object') {
15
17
  // @ts-ignore Forced cast to object
@@ -19,30 +21,119 @@ export function applyMergePatch(target, patch) {
19
21
  if (value == null) {
20
22
  // @ts-ignore Runtime check
21
23
  if (key in target) {
22
- // @ts-ignore T1 is always object
24
+ // @ts-ignore target is object
23
25
  delete target[key];
24
26
  }
25
27
  } else {
26
- // @ts-ignore T1 is forced object
27
- target[key] = applyMergePatch(target[key], value);
28
+ // @ts-ignore target is object
29
+ target[key] = applyMergePatchObject(target[key], value);
28
30
  }
29
31
  }
30
32
  return target;
31
33
  }
32
34
 
35
+ /**
36
+ * RFC 7396 semantics: only JSON objects are merged. Arrays are replaced.
37
+ * @template T1
38
+ * @template T2
39
+ * @param {T1} target
40
+ * @param {T2} patch
41
+ * @return {T1|T2|(T1 & T2)}
42
+ */
43
+ function applyMergePatchReference(target, patch) {
44
+ // @ts-ignore Runtime check
45
+ if (target === patch) return target;
46
+ const patchIsObject = patch != null && typeof patch === 'object' && !Array.isArray(patch);
47
+ const targetIsObject = target != null && typeof target === 'object' && !Array.isArray(target);
48
+ if (!patchIsObject || !targetIsObject) return patch;
49
+ for (const [key, value] of Object.entries(patch)) {
50
+ if (value == null) {
51
+ // @ts-ignore Runtime check
52
+ if (key in target) {
53
+ // @ts-ignore target is object
54
+ delete target[key];
55
+ }
56
+ } else {
57
+ // @ts-ignore target is object
58
+ target[key] = applyMergePatchReference(target[key], value);
59
+ }
60
+ }
61
+ return target;
62
+ }
63
+
64
+ /**
65
+ * @template T1
66
+ * @template T2
67
+ * @param {T1} target
68
+ * @param {T2} patch
69
+ * @param {'clone'|'object'|'reference'} [arrayStrategy='reference']
70
+ * @return {T1|T2|(T1 & T2)}
71
+ */
72
+ export function applyMergePatch(target, patch, arrayStrategy = 'reference') {
73
+ switch (arrayStrategy) {
74
+ case 'clone':
75
+ case 'object':
76
+ return applyMergePatchObject(target, patch);
77
+ default:
78
+ return applyMergePatchReference(target, patch);
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Creates a JSON Merge patch based
84
+ * Allows different strategies for arrays
85
+ * - `reference`: Returns array as-is (replacement).
86
+ * @param {object|number|string|boolean} previous
87
+ * @param {object|number|string|boolean} current
88
+ * @return {any} Patch
89
+ */
90
+ export function buildMergePatchReference(previous, current) {
91
+ if (previous === current) return null;
92
+ if (current == null || typeof current !== 'object') return current;
93
+ if (previous == null || typeof previous !== 'object') {
94
+ return structuredClone(current);
95
+ }
96
+
97
+ const patch = {};
98
+ if (Array.isArray(current)) {
99
+ return current;
100
+ }
101
+
102
+ const previousKeys = new Set(Object.keys(previous));
103
+ for (const [key, value] of Object.entries(current)) {
104
+ previousKeys.delete(key);
105
+ if (value === null) {
106
+ // @ts-ignore patch is Object
107
+ patch[key] = null;
108
+ continue;
109
+ }
110
+ if (value == null) {
111
+ continue; // Skip undefined
112
+ }
113
+ // @ts-ignore previous is Object
114
+ const changes = buildMergePatchReference(previous[key], value);
115
+ if (changes !== null) {
116
+ // @ts-ignore patch is Object
117
+ patch[key] = changes;
118
+ }
119
+ }
120
+ for (const key of previousKeys) {
121
+ // @ts-ignore patch is Object
122
+ patch[key] = null;
123
+ }
124
+
125
+ return patch;
126
+ }
127
+
33
128
  /**
34
129
  * Creates a JSON Merge patch based
35
130
  * Allows different strategies for arrays
36
- * - `reference`: Per spec, returns array as is
37
131
  * - `clone`: Clones all entries with no inspection.
38
- * - `object`: Convert to flattened, array-like objects. Requires
39
- * consumer of patch to be aware of the schema beforehand.
40
132
  * @param {object|number|string|boolean} previous
41
133
  * @param {object|number|string|boolean} current
42
- * @param {'clone'|'object'|'reference'} [arrayStrategy='reference']
43
134
  * @return {any} Patch
44
135
  */
45
- export function buildMergePatch(previous, current, arrayStrategy = 'reference') {
136
+ export function buildMergePatchClone(previous, current) {
46
137
  if (previous === current) return null;
47
138
  if (current == null || typeof current !== 'object') return current;
48
139
  if (previous == null || typeof previous !== 'object') {
@@ -51,13 +142,53 @@ export function buildMergePatch(previous, current, arrayStrategy = 'reference')
51
142
 
52
143
  const patch = {};
53
144
  if (Array.isArray(current)) {
54
- if (arrayStrategy === 'reference') {
55
- return current;
145
+ return structuredClone(current);
146
+ }
147
+
148
+ const previousKeys = new Set(Object.keys(previous));
149
+ for (const [key, value] of Object.entries(current)) {
150
+ previousKeys.delete(key);
151
+ if (value === null) {
152
+ // @ts-ignore patch is Object
153
+ patch[key] = null;
154
+ continue;
56
155
  }
57
- // Assume previous is array
58
- if (arrayStrategy === 'clone') {
59
- return structuredClone(current);
156
+ if (value == null) {
157
+ continue; // Skip undefined
158
+ }
159
+ // @ts-ignore previous is Object
160
+ const changes = buildMergePatchClone(previous[key], value);
161
+ if (changes !== null) {
162
+ // @ts-ignore patch is Object
163
+ patch[key] = changes;
60
164
  }
165
+ }
166
+ for (const key of previousKeys) {
167
+ // @ts-ignore patch is Object
168
+ patch[key] = null;
169
+ }
170
+
171
+ return patch;
172
+ }
173
+
174
+ /**
175
+ * Creates a JSON Merge patch based
176
+ * Allows different strategies for arrays
177
+ * - `object`: Convert to flattened, array-like objects. Requires
178
+ * consumer of patch to be aware of the schema beforehand.
179
+ * @param {object|number|string|boolean} previous
180
+ * @param {object|number|string|boolean} current
181
+ * @return {any} Patch
182
+ */
183
+ export function buildMergePatchObject(previous, current) {
184
+ if (previous === current) return null;
185
+ if (current == null || typeof current !== 'object') return current;
186
+ if (previous == null || typeof previous !== 'object') {
187
+ return structuredClone(current);
188
+ }
189
+
190
+ const patch = {};
191
+ if (Array.isArray(current)) {
61
192
  for (const [index, value] of current.entries()) {
62
193
  if (value === null) {
63
194
  // @ts-ignore patch is ArrayLike
@@ -68,7 +199,7 @@ export function buildMergePatch(previous, current, arrayStrategy = 'reference')
68
199
  continue; // Skip undefined
69
200
  }
70
201
  // @ts-ignore previous is ArrayLike
71
- const changes = buildMergePatch(previous[index], value, arrayStrategy);
202
+ const changes = buildMergePatchObject(previous[index], value);
72
203
  if (changes !== null) {
73
204
  // @ts-ignore patch is ArrayLike
74
205
  patch[index] = changes;
@@ -96,7 +227,7 @@ export function buildMergePatch(previous, current, arrayStrategy = 'reference')
96
227
  continue; // Skip undefined
97
228
  }
98
229
  // @ts-ignore previous is Object
99
- const changes = buildMergePatch(previous[key], value, arrayStrategy);
230
+ const changes = buildMergePatchObject(previous[key], value);
100
231
  if (changes !== null) {
101
232
  // @ts-ignore patch is Object
102
233
  patch[key] = changes;
@@ -111,13 +242,36 @@ export function buildMergePatch(previous, current, arrayStrategy = 'reference')
111
242
  }
112
243
 
113
244
  /**
114
- * Short-circuited JSON Merge Patch evaluation
245
+ * Creates a JSON Merge patch based
246
+ * Allows different strategies for arrays
247
+ * - `reference`: Per spec, returns array as is
248
+ * - `clone`: Clones all entries with no inspection.
249
+ * - `object`: Convert to flattened, array-like objects. Requires
250
+ * consumer of patch to be aware of the schema beforehand.
251
+ * @param {object|number|string|boolean} previous
252
+ * @param {object|number|string|boolean} current
253
+ * @param {'clone'|'object'|'reference'} [arrayStrategy='reference']
254
+ * @return {any} Patch
255
+ */
256
+ export function buildMergePatch(previous, current, arrayStrategy = 'reference') {
257
+ switch (arrayStrategy) {
258
+ case 'clone':
259
+ return buildMergePatchClone(previous, current);
260
+ case 'object':
261
+ return buildMergePatchObject(previous, current);
262
+ default:
263
+ return buildMergePatchReference(previous, current);
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Default behavior: arrays are treated like objects (index/length merge).
115
269
  * @template T
116
270
  * @param {T} target
117
271
  * @param {Partial<T>} patch
118
272
  * @return {boolean}
119
273
  */
120
- export function hasMergePatch(target, patch) {
274
+ export function hasMergePatchReference(target, patch) {
121
275
  if (target === patch) return false;
122
276
  if (patch == null || typeof patch !== 'object') return true;
123
277
  if (target != null && typeof target !== 'object') {
@@ -130,9 +284,38 @@ export function hasMergePatch(target, patch) {
130
284
  return true;
131
285
  }
132
286
  // @ts-ignore T is object
133
- } else if (hasMergePatch(target[key], value)) {
287
+ } else if (hasMergePatchReference(target[key], value)) {
134
288
  return true;
135
289
  }
136
290
  }
137
291
  return false;
138
292
  }
293
+
294
+ /**
295
+ * Object-merge semantics for arrays (alias of reference strategy).
296
+ * @template T
297
+ * @param {T} target
298
+ * @param {Partial<T>} patch
299
+ * @return {boolean}
300
+ */
301
+ export function hasMergePatchObject(target, patch) {
302
+ if (Array.isArray(patch)) return target !== patch;
303
+ return hasMergePatchReference(target, patch);
304
+ }
305
+
306
+ /**
307
+ * @template T
308
+ * @param {T} target
309
+ * @param {Partial<T>} patch
310
+ * @param {'clone'|'object'|'reference'} [arrayStrategy='reference']
311
+ * @return {boolean}
312
+ */
313
+ export function hasMergePatch(target, patch, arrayStrategy = 'reference') {
314
+ switch (arrayStrategy) {
315
+ case 'clone':
316
+ case 'object':
317
+ return hasMergePatchObject(target, patch);
318
+ default:
319
+ return hasMergePatchReference(target, patch);
320
+ }
321
+ }