cocooned 1.4.1 → 2.0.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.
@@ -1,480 +1,1010 @@
1
- /* globals define */
2
-
3
- // Use Universal Module Definition pattern to load Cocooned
4
- // See https://github.com/umdjs/umd/blob/master/templates/returnExportsGlobal.js
5
- (function (root, factory) {
6
- if (typeof define === 'function' && define.amd) {
7
- // AMD. Register as an anonymous module.
8
- define(['jquery'], function (jquery) {
9
- return (root.Cocooned = factory(jquery));
1
+ (function (global, factory) {
2
+ typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
3
+ typeof define === 'function' && define.amd ? define(factory) :
4
+ (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.Cocooned = factory());
5
+ })(this, (function () { 'use strict';
6
+
7
+ class Emitter {
8
+ constructor (namespaces = ['cocooned']) {
9
+ this.#namespaces = namespaces;
10
+ }
11
+
12
+ emit (target, type, detail = {}) {
13
+ return !this.#emitted(target, type, detail).some(e => e.defaultPrevented)
14
+ }
15
+
16
+ /* Protected and private attributes and methods */
17
+ #namespaces
18
+
19
+ #emitted (target, type, detail = {}) {
20
+ const events = this.#events(type, detail);
21
+ events.forEach(e => this.#dispatch(target, e));
22
+
23
+ return events
24
+ }
25
+
26
+ #dispatch (target, event) {
27
+ return target.dispatchEvent(event)
28
+ }
29
+
30
+ #events (type, detail) {
31
+ return this.#namespaces.map(ns => this.#event(`${ns}:${type}`, detail))
32
+ }
33
+
34
+ #event (type, detail) {
35
+ return new CustomEvent(type, { bubbles: true, cancelable: true, detail })
36
+ }
37
+ }
38
+
39
+ // Borrowed from <https://stackoverflow.com/a/2117523>
40
+ function uuidv4 () {
41
+ return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c =>
42
+ (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
43
+ )
44
+ }
45
+
46
+ function hideMarkedForDestruction (cocooned, items) {
47
+ items.forEach(item => {
48
+ const destroy = item.querySelector('input[type=hidden][name$="[_destroy]"]');
49
+ if (destroy === null) {
50
+ return
51
+ }
52
+ if (destroy.getAttribute('value') !== 'true') {
53
+ return
54
+ }
55
+
56
+ cocooned.hide(item, { animate: false });
10
57
  });
11
- } else if (typeof module === 'object' && module.exports) {
12
- // Node. Does not work with strict CommonJS, but
13
- // only CommonJS-like environments that support module.exports,
14
- // like Node.
15
- module.exports = factory(require('jquery'));
16
- } else {
17
- // Browser globals
18
- root.Cocooned = factory(root.jQuery);
19
58
  }
20
- }(typeof self !== 'undefined' ? self : this, function ($) {
21
- var Cocooned = function (container, options) {
22
- this.container = $(container);
23
- var opts = $.extend({}, this.defaultOptions(), (options || {}));
24
-
25
- // Autoload plugins
26
- for (var pluginName in Cocooned.Plugins) {
27
- if (Cocooned.Plugins.hasOwnProperty(pluginName)) {
28
- var plugin = Cocooned.Plugins[pluginName];
29
- var optionName = pluginName.charAt(0).toLowerCase() + pluginName.slice(1);
30
-
31
- if (opts[optionName] !== false) {
32
- if (plugin.hasOwnProperty('normalizeConfig') && typeof plugin['normalizeConfig'] === 'function') {
33
- opts[optionName] = plugin.normalizeConfig(opts[optionName]);
34
- }
35
-
36
- for (var method in plugin) {
37
- if (method === 'normalizeConfig') {
38
- continue;
39
- }
40
- if (!plugin.hasOwnProperty(method) || typeof plugin[method] !== 'function') {
41
- continue;
42
- }
43
-
44
- this[method] = plugin[method];
45
- }
46
- }
59
+
60
+ function defaultAnimator (item, fetch = false) {
61
+ if (fetch) {
62
+ item.dataset.cocoonedScrollHeight = item.scrollHeight;
63
+ }
64
+
65
+ return [
66
+ { height: `${item.dataset.cocoonedScrollHeight}px`, opacity: 1 },
67
+ { height: `${item.dataset.cocoonedScrollHeight}px`, opacity: 0 },
68
+ { height: 0, opacity: 0 }
69
+ ]
70
+ }
71
+
72
+ const instances = Object.create(null);
73
+
74
+ class Base {
75
+ static get defaultOptions () {
76
+ const element = document.createElement('div');
77
+ return {
78
+ animate: ('animate' in element && typeof element.animate === 'function'),
79
+ animator: defaultAnimator,
80
+ duration: 450
47
81
  }
48
82
  }
49
83
 
50
- this.options = opts;
51
- this.init();
52
- };
84
+ static get eventNamespaces () {
85
+ return ['cocooned']
86
+ }
53
87
 
54
- Cocooned.Plugins = {};
55
- Cocooned.prototype = {
56
-
57
- elementsCounter: 0,
58
-
59
- // Compatibility with Cocoon
60
- // TODO: Remove in 3.0 (Only Cocoon namespaces).
61
- namespaces: {
62
- events: ['cocooned', 'cocoon']
63
- },
64
-
65
- // Compatibility with Cocoon
66
- // TODO: Remove in 3.0 (Only Cocoon class names).
67
- classes: {
68
- // Actions link
69
- add: ['cocooned-add', 'add_fields'],
70
- remove: ['cocooned-remove', 'remove_fields'],
71
- up: ['cocooned-move-up'],
72
- down: ['cocooned-move-down'],
73
- // Containers
74
- container: ['cocooned-container'],
75
- item: ['cocooned-item', 'nested-fields']
76
- },
77
-
78
- defaultOptions: function () {
79
- var options = {};
80
-
81
- for (var moduleName in Cocooned.Plugins) {
82
- if (Cocooned.Plugins.hasOwnProperty(moduleName)) {
83
- var module = Cocooned.Plugins[moduleName];
84
- var optionName = moduleName.charAt(0).toLowerCase() + moduleName.slice(1);
85
-
86
- options[optionName] = module.defaultOptionValue;
87
- }
88
+ static get selectors () {
89
+ return {
90
+ container: ['[data-cocooned-container]', '.cocooned-container'],
91
+ item: ['[data-cocooned-item]', '.cocooned-item']
88
92
  }
93
+ }
89
94
 
90
- return options;
91
- },
95
+ static getInstance (uuid) {
96
+ return instances[uuid]
97
+ }
92
98
 
93
- notify: function (node, eventType, eventData) {
94
- return !(this.namespaces.events.some(function (namespace) {
95
- var namespacedEventType = [namespace, eventType].join(':');
96
- var event = $.Event(namespacedEventType, eventData);
99
+ constructor (container, options) {
100
+ this._container = container;
101
+ this._uuid = uuidv4();
102
+ this._options = this.constructor._normalizeOptions({
103
+ ...this.constructor.defaultOptions,
104
+ ...('cocoonedOptions' in container.dataset ? JSON.parse(container.dataset.cocoonedOptions) : {}),
105
+ ...(options || {})
106
+ });
107
+ }
97
108
 
98
- node.trigger(event, [eventData.node, eventData.cocooned]);
109
+ get container () {
110
+ return this._container
111
+ }
99
112
 
100
- return (event.isPropagationStopped() || event.isDefaultPrevented());
101
- }));
102
- },
113
+ get options () {
114
+ return this._options
115
+ }
103
116
 
104
- selector: function (type, selector) {
105
- var s = selector || '&';
106
- return this.classes[type].map(function (klass) { return s.replace(/&/, '.' + klass); }).join(', ');
107
- },
117
+ start () {
118
+ this.container.dataset.cocoonedContainer = true;
119
+ this.container.dataset.cocoonedUuid = this._uuid;
120
+ instances[this._uuid] = this;
108
121
 
109
- namespacedNativeEvents: function (type) {
110
- var namespaces = this.namespaces.events.map(function (ns) { return '.' + ns; });
111
- namespaces.unshift(type);
112
- return namespaces.join('');
113
- },
122
+ const hideDestroyed = () => { hideMarkedForDestruction(this, this.items); };
123
+
124
+ hideDestroyed();
125
+ this.container.ownerDocument.addEventListener('page:load', hideDestroyed);
126
+ this.container.ownerDocument.addEventListener('turbo:load', hideDestroyed);
127
+ this.container.ownerDocument.addEventListener('turbolinks:load', hideDestroyed);
128
+ }
114
129
 
115
- buildId: function () {
116
- return (new Date().getTime() + this.elementsCounter++);
117
- },
130
+ notify (node, eventType, eventData) {
131
+ return this._emitter.emit(node, eventType, eventData)
132
+ }
118
133
 
119
- buildContentNode: function (content) {
120
- var id = this.buildId();
121
- var html = (content || this.content);
122
- var braced = '[' + id + ']';
123
- var underscored = '_' + id + '_';
134
+ /* Selections methods */
135
+ get items () {
136
+ return Array.from(this.container.querySelectorAll(this._selector('item')))
137
+ .filter(item => this.toContainer(item) === this.container)
138
+ .filter(item => !('display' in item.style && item.style.display === 'none'))
139
+ }
124
140
 
125
- ['associations', 'association'].forEach(function (a) {
126
- html = html.replace(this.regexps[a]['braced'], braced + '$1');
127
- html = html.replace(this.regexps[a]['underscored'], underscored + '$1');
128
- }, this);
141
+ toContainer (node) {
142
+ return node.closest(this._selector('container'))
143
+ }
129
144
 
130
- return $(html);
131
- },
145
+ toItem (node) {
146
+ return node.closest(this._selector('item'))
147
+ }
132
148
 
133
- getInsertionNode: function (adder) {
134
- var $adder = $(adder);
135
- var insertionNode = $adder.data('association-insertion-node');
136
- var insertionTraversal = $adder.data('association-insertion-traversal');
149
+ contains (node) {
150
+ return this.items.includes(this.toItem(node))
151
+ }
137
152
 
138
- if (!insertionNode) {
139
- return $adder.parent();
153
+ hide (item, options = {}) {
154
+ const opts = this._animationOptions(options);
155
+ const keyframes = opts.animator(item, true);
156
+ const after = () => { item.style.display = 'none'; };
157
+
158
+ if (!opts.animate) {
159
+ return Promise.resolve(after()).then(() => item)
140
160
  }
161
+ return item.animate(keyframes, opts.duration).finished.then(after).then(() => item)
162
+ }
163
+
164
+ show (item, options = {}) {
165
+ const opts = this._animationOptions(options);
166
+ const keyframes = opts.animator(item, false).reverse();
167
+ const before = () => { item.style.display = null; };
141
168
 
142
- if (typeof insertionNode === 'function') {
143
- return insertionNode($adder);
169
+ const promise = Promise.resolve(before());
170
+ if (!opts.animate) {
171
+ return promise.then(() => item)
144
172
  }
173
+ return promise.then(() => item.animate(keyframes, opts.duration).finished).then(() => item)
174
+ }
175
+
176
+ /* Protected and private attributes and methods */
177
+ static _normalizeOptions (options) {
178
+ return options
179
+ }
145
180
 
146
- if (insertionTraversal) {
147
- return $adder[insertionTraversal](insertionNode);
181
+ _container
182
+ _options
183
+ __uuid
184
+ __emitter
185
+
186
+ get _emitter () {
187
+ if (typeof this.__emitter === 'undefined') {
188
+ this.__emitter = new Emitter(this.constructor.eventNamespaces);
148
189
  }
149
190
 
150
- return insertionNode === 'this' ? $adder : $(insertionNode);
151
- },
191
+ return this.__emitter
192
+ }
152
193
 
153
- getInsertionMethod: function (adder) {
154
- var $adder = $(adder);
155
- return $adder.data('association-insertion-method') || 'before';
156
- },
194
+ _selectors (name) {
195
+ return this.constructor.selectors[name]
196
+ }
157
197
 
158
- getItems: function (selector) {
159
- selector = selector || '';
160
- var self = this;
161
- return $(this.selector('item', selector), this.container).filter(function () {
162
- return ($(this).closest(self.selector('container')).get(0) === self.container.get(0));
163
- });
164
- },
198
+ _selector (name) {
199
+ return this._selectors(name).join(', ')
200
+ }
201
+
202
+ _animationOptions (options) {
203
+ const defaults = (({ animate, animator, duration }) => ({ animate, animator, duration }))(this._options);
204
+ return { ...defaults, ...options }
205
+ }
206
+ }
207
+
208
+ class Trigger {
209
+ constructor (trigger, cocooned) {
210
+ this._trigger = trigger;
211
+ this._cocooned = cocooned;
212
+ }
213
+
214
+ get trigger () {
215
+ return this._trigger
216
+ }
217
+
218
+ handle (event) {
219
+ throw new TypeError('handle() must be defined in subclasses')
220
+ }
221
+
222
+ /* Protected and private attributes and methods */
223
+ _cocooned
224
+ _trigger
165
225
 
166
- findContainer: function (addLink) {
167
- var $adder = $(addLink);
168
- var insertionNode = this.getInsertionNode($adder);
169
- var insertionMethod = this.getInsertionMethod($adder);
226
+ get _item () {
227
+ return this._cocooned.toItem(this._trigger)
228
+ }
229
+
230
+ get _notified () {
231
+ return this._item
232
+ }
170
233
 
171
- switch (insertionMethod) {
172
- case 'before':
173
- case 'after':
174
- case 'replaceWith':
175
- return insertionNode.parent();
234
+ _notify (eventName, originalEvent) {
235
+ return this._cocooned.notify(this._notified, eventName, this._eventData(originalEvent))
236
+ }
237
+
238
+ _eventData (originalEvent) {
239
+ return { link: this._trigger, node: this._item, cocooned: this._cocooned, originalEvent }
240
+ }
241
+
242
+ _hide (node, callback) {
243
+ return this._cocooned.hide(node, callback)
244
+ }
245
+
246
+ _show (node, callback) {
247
+ return this._cocooned.show(node, callback)
248
+ }
249
+ }
176
250
 
177
- case 'append':
178
- case 'prepend':
179
- default:
180
- return insertionNode;
251
+ /**
252
+ * Borrowed from Lodash
253
+ * See https://lodash.com/docs/#escapeRegExp
254
+ */
255
+ const reRegExpChar = /[\\^$.*+?()[\]{}|]/g;
256
+ const reHasRegExpChar = RegExp(reRegExpChar.source);
257
+
258
+ class Replacement {
259
+ attribute
260
+
261
+ constructor (attribute, name, startDelimiter, endDelimiter = null) {
262
+ this.attribute = attribute;
263
+
264
+ this.#name = name;
265
+ this.#startDelimiter = startDelimiter;
266
+ this.#endDelimiter = endDelimiter || startDelimiter;
267
+ }
268
+
269
+ apply (node, id) {
270
+ const value = node.getAttribute(this.attribute);
271
+ if (!this.#regexp.test(value)) {
272
+ return
181
273
  }
182
- },
183
274
 
184
- findItem: function (removeLink) {
185
- return $(removeLink).closest(this.selector('item'));
186
- },
275
+ node.setAttribute(this.attribute, value.replace(this.#regexp, this.#replacement(id)));
276
+ }
277
+
278
+ /* Protected and private attributes and methods */
279
+ #name
280
+ #startDelimiter
281
+ #endDelimiter
187
282
 
188
- init: function () {
189
- var self = this;
283
+ #replacement (id) {
284
+ return `${this.#startDelimiter}${id}${this.#endDelimiter}$1`
285
+ }
286
+
287
+ get #regexp () {
288
+ const escaped = this.#escape(`${this.#startDelimiter}${this.#name}${this.#endDelimiter}`);
289
+ return new RegExp(`${escaped}(.*?)`, 'g')
290
+ }
190
291
 
191
- this.addLinks = $(this.selector('add')).filter(function () {
192
- var container = self.findContainer(this);
193
- return (container.get(0) === self.container.get(0));
292
+ #escape (string) {
293
+ return (string && reHasRegExpChar.test(string))
294
+ ? string.replace(reRegExpChar, '\\$&')
295
+ : (string || '')
296
+ }
297
+ }
298
+
299
+ class Builder {
300
+ constructor (documentFragment, association) {
301
+ this.#documentFragment = documentFragment;
302
+ this.#association = association;
303
+ this.#replacements = [
304
+ new Replacement('for', association, '_'),
305
+ new Replacement('id', association, '_'),
306
+ new Replacement('name', association, '[', ']')
307
+ ];
308
+ }
309
+
310
+ build (id) {
311
+ const node = this.#documentFragment.cloneNode(true);
312
+ this.#replacements.forEach(replacement => {
313
+ node.querySelectorAll(`*[${replacement.attribute}]`).forEach(node => replacement.apply(node, id));
194
314
  });
195
315
 
196
- var addLink = $(this.addLinks.get(0));
197
-
198
- this.content = addLink.data('association-insertion-template');
199
- this.regexps = {
200
- association: {
201
- braced: new RegExp('\\[new_' + addLink.data('association') + '\\](.*?\\s)', 'g'),
202
- underscored: new RegExp('_new_' + addLink.data('association') + '_(\\w*)', 'g')
203
- },
204
- associations: {
205
- braced: new RegExp('\\[new_' + addLink.data('associations') + '\\](.*?\\s)', 'g'),
206
- underscored: new RegExp('_new_' + addLink.data('associations') + '_(\\w*)', 'g')
207
- }
208
- };
209
-
210
- this.initUi();
211
- this.bindEvents();
212
- },
213
-
214
- initUi: function () {
215
- var self = this;
216
-
217
- if (!this.container.attr('id')) {
218
- this.container.attr('id', this.buildId());
219
- }
220
- this.container.addClass(this.classes['container'].join(' '));
221
-
222
- $(function () { self.hideMarkedForDestruction(); });
223
- $(document).on('page:load turbolinks:load turbo:load', function () { self.hideMarkedForDestruction(); });
224
- },
225
-
226
- bindEvents: function () {
227
- var self = this;
228
-
229
- // Bind add links
230
- this.addLinks.on(
231
- this.namespacedNativeEvents('click'),
232
- function (e) {
233
- e.preventDefault();
234
- e.stopPropagation();
235
-
236
- self.add(this, e);
237
- });
238
-
239
- // Bind remove links
240
- // (Binded on document instead of container to not bypass click handler defined in jquery_ujs)
241
- $(document).on(
242
- this.namespacedNativeEvents('click'),
243
- this.selector('remove', '#' + this.container.attr('id') + ' &'),
244
- function (e) {
245
- e.preventDefault();
246
- e.stopPropagation();
247
-
248
- self.remove(this, e);
249
- });
250
-
251
- // Bind options events
252
- $.each(this.options, function (name, value) {
253
- var bindMethod = 'bind' + name.charAt(0).toUpperCase() + name.slice(1);
254
- if (value && self[bindMethod]) {
255
- self[bindMethod]();
316
+ return node
317
+ }
318
+
319
+ /* Protected and private attributes and methods */
320
+ #association
321
+ #documentFragment
322
+ #replacements
323
+ }
324
+
325
+ class Traverser {
326
+ constructor (origin, traversal) {
327
+ this.#origin = origin;
328
+ this.#traversal = traversal;
329
+ }
330
+
331
+ resolve (selector) {
332
+ if (this.#traversal in this.#origin && typeof this.#origin[this.#traversal] === 'function') {
333
+ return this._tryMethod(this.#traversal, selector)
334
+ }
335
+
336
+ if (this.#traversal in this.#origin) {
337
+ return this._tryProperty(this.#traversal)
338
+ }
339
+
340
+ const method = `_${this.#traversal}`;
341
+ if (method in this) {
342
+ return this[method](selector)
343
+ }
344
+
345
+ return null
346
+ }
347
+
348
+ /* Protected and private attributes and methods */
349
+ #origin
350
+ #traversal
351
+
352
+ _tryMethod (method, selector) {
353
+ try {
354
+ const resolved = this.#origin[method](selector);
355
+ if (resolved instanceof HTMLElement) {
356
+ return resolved
256
357
  }
257
- });
258
- },
259
-
260
- add: function (adder, originalEvent) {
261
- var $adder = $(adder);
262
- var insertionMethod = this.getInsertionMethod($adder);
263
- var insertionNode = this.getInsertionNode($adder);
264
- var contentTemplate = $adder.data('association-insertion-template');
265
- var count = parseInt($adder.data('count'), 10) || 1;
266
-
267
- for (var i = 0; i < count; i++) {
268
- var contentNode = this.buildContentNode(contentTemplate);
269
- var eventData = { link: $adder, node: contentNode, cocooned: this, originalEvent: originalEvent };
270
- var afterNode = (insertionMethod === 'replaceWith' ? contentNode : insertionNode);
271
-
272
- // Insertion can be prevented through a 'cocooned:before-insert' event handler
273
- if (!this.notify(insertionNode, 'before-insert', eventData)) {
274
- return false;
358
+ } catch (e) {}
359
+
360
+ return null
361
+ }
362
+
363
+ _tryProperty (property) {
364
+ const resolved = this.#origin[property];
365
+ if (resolved instanceof HTMLElement) {
366
+ return resolved
367
+ }
368
+
369
+ return null
370
+ }
371
+
372
+ _parent (selector) {
373
+ if (this.#origin.parentElement.matches(selector)) {
374
+ return this.#origin.parentElement
375
+ }
376
+ return null
377
+ }
378
+
379
+ _prev (selector) {
380
+ if (this.#origin.previousElementSibling.matches(selector)) {
381
+ return this.#origin.previousElementSibling
382
+ }
383
+ return null
384
+ }
385
+
386
+ _next (selector) {
387
+ if (this.#origin.nextElementSibling.matches(selector)) {
388
+ return this.#origin.nextElementSibling
389
+ }
390
+ return null
391
+ }
392
+
393
+ _siblings (selector) {
394
+ return this.#origin.parentElement.querySelector(selector)
395
+ }
396
+ }
397
+
398
+ class Deprecator {
399
+ logger
400
+ package
401
+ version
402
+
403
+ constructor (version, packageName, logger) {
404
+ this.version = version;
405
+ this.package = packageName;
406
+ this.logger = logger;
407
+ }
408
+
409
+ warn (message, replacement = null) {
410
+ if (message in this.#emitted) {
411
+ return
412
+ }
413
+
414
+ const warning = `${message}. It will be removed from ${this.package} ${this.version}`;
415
+ const alternative = (replacement !== null ? `, use ${replacement} instead` : '');
416
+ this.logger.warn(`DEPRECATION WARNING: ${warning}${alternative}.`);
417
+
418
+ this.#emitted[message] = true;
419
+ }
420
+
421
+ /* Protected and private attributes and methods */
422
+ #emitted = Object.create(null)
423
+ }
424
+
425
+ const deprecators = Object.create(null);
426
+
427
+ function deprecator (version, packageName = 'Cocooned', logger = console) {
428
+ const hash = [version, packageName].join('#');
429
+ if (!(hash in deprecators)) {
430
+ deprecators[hash] = new Deprecator(version, packageName, logger);
431
+ }
432
+
433
+ return deprecators[hash]
434
+ }
435
+
436
+ class Extractor {
437
+ constructor (trigger) {
438
+ this.#trigger = trigger;
439
+ }
440
+
441
+ extract () {
442
+ return ['builder', 'count', 'node', 'method'].reduce((options, option) => {
443
+ // Sadly, this does not seem to work with #privateMethods
444
+ const method = `_extract${option.charAt(0).toUpperCase() + option.slice(1)}`;
445
+ const extracted = this[method]();
446
+ if (extracted !== null) {
447
+ options[option] = extracted;
275
448
  }
276
449
 
277
- insertionNode[insertionMethod](contentNode);
450
+ return options
451
+ }, {})
452
+ }
453
+
454
+ /* Protected and private attributes and methods */
455
+ #trigger
456
+
457
+ get #dataset () {
458
+ return this.#trigger.dataset
459
+ }
460
+
461
+ _extractBuilder () {
462
+ if (!('template' in this.#dataset && 'association' in this.#dataset)) {
463
+ return null
464
+ }
465
+
466
+ const template = document.querySelector(`template[data-name="${this.#dataset.template}"]`);
467
+ if (template === null) {
468
+ return null
469
+ }
470
+
471
+ return new Builder(template.content, `new_${this.#dataset.association}`)
472
+ }
473
+
474
+ _extractCount () {
475
+ if ('associationInsertionCount' in this.#dataset) {
476
+ return parseInt(this.#dataset.associationInsertionCount, 10)
477
+ }
478
+
479
+ if ('count' in this.#dataset) {
480
+ return parseInt(this.#dataset.count, 10)
481
+ }
482
+
483
+ return null
484
+ }
485
+
486
+ _extractMethod () {
487
+ if ('associationInsertionMethod' in this.#dataset) {
488
+ return this.#dataset.associationInsertionMethod
489
+ }
490
+
491
+ return 'before'
492
+ }
493
+
494
+ _extractNode () {
495
+ if (!('associationInsertionNode' in this.#dataset)) {
496
+ return this.#trigger.parentElement
497
+ }
498
+
499
+ const node = this.#dataset.associationInsertionNode;
500
+ if (node === 'this') {
501
+ return this.#trigger
502
+ }
503
+
504
+ if (!('associationInsertionTraversal' in this.#dataset)) {
505
+ return this.#trigger.ownerDocument.querySelector(node)
506
+ }
507
+
508
+ deprecator('3.0').warn('associationInsertionTraversal is deprecated');
509
+ const traverser = new Traverser(this.#trigger, this.#dataset.associationInsertionTraversal);
510
+
511
+ return traverser.resolve(node)
512
+ }
513
+ }
514
+
515
+ class Validator {
516
+ static validates (options) {
517
+ const validator = new Validator(options);
518
+ return validator.validates()
519
+ }
520
+
521
+ constructor (options) {
522
+ this.#options = options;
523
+ }
524
+
525
+ validates () {
526
+ const optionNames = new Set(Object.keys(this.#options));
527
+ const expected = new Set(['builder', 'count', 'node', 'method']);
528
+ const missing = new Set(Array.from(expected.values()).filter(key => !optionNames.has(key)));
529
+
530
+ if (missing.size !== 0) {
531
+ throw new TypeError(`Missing options: ${Array.from(missing.values()).join(', ')}`)
532
+ }
533
+
534
+ this._validateBuilder();
535
+ this._validateMethod();
536
+ }
537
+
538
+ /* Protected and private attributes and methods */
539
+ #options
278
540
 
279
- this.notify(afterNode, 'after-insert', eventData);
541
+ _validateBuilder () {
542
+ const builder = this.#options.builder;
543
+ if (!(builder instanceof Builder)) {
544
+ throw new TypeError(
545
+ `Invalid builder option: instance of Builder expected, got ${builder.constructor.name}`
546
+ )
280
547
  }
281
- },
548
+ }
282
549
 
283
- remove: function (remover, originalEvent) {
284
- var self = this;
285
- var $remover = $(remover);
286
- var nodeToDelete = this.findItem($remover);
287
- var triggerNode = nodeToDelete.parent();
288
- var eventData = { link: $remover, node: nodeToDelete, cocooned: this, originalEvent: originalEvent };
550
+ _validateMethod () {
551
+ const method = this.#options.method;
552
+ const supported = ['after', 'before', 'append', 'prepend', 'replaceWith'];
289
553
 
290
- // Deletion can be prevented through a 'cocooned:before-remove' event handler
291
- if (!this.notify(triggerNode, 'before-remove', eventData)) {
292
- return false;
554
+ if (!supported.includes(method)) {
555
+ throw new TypeError(
556
+ `Invalid method option: expected one of ${supported.join(', ')}, got ${method}`
557
+ )
293
558
  }
559
+ }
560
+ }
561
+
562
+ let counter = 0;
563
+
564
+ function uniqueId () {
565
+ return `${new Date().getTime()}${counter++}`
566
+ }
567
+
568
+ class Add extends Trigger {
569
+ static create (trigger, cocooned) {
570
+ const extractor = new Extractor(trigger);
571
+ return new Add(trigger, cocooned, extractor.extract())
572
+ }
294
573
 
295
- var timeout = triggerNode.data('remove-timeout') || 0;
574
+ constructor (trigger, cocooned, options = {}) {
575
+ super(trigger, cocooned);
296
576
 
297
- setTimeout(function () {
298
- if ($remover.hasClass('dynamic')) {
299
- nodeToDelete.remove();
300
- } else {
301
- nodeToDelete.find('input[required], select[required]').each(function (index, element) {
302
- $(element).removeAttr('required');
303
- });
304
- $remover.siblings('input[type=hidden][name$="[_destroy]"]').val('true');
305
- nodeToDelete.hide();
577
+ this.#options = { ...this.#options, ...options };
578
+ Validator.validates(this.#options);
579
+ }
580
+
581
+ get insertionNode () {
582
+ return this.#options.node
583
+ }
584
+
585
+ handle (event) {
586
+ for (let i = 0; i < this.#options.count; i++) {
587
+ this.#item = this._build();
588
+
589
+ // Insert can be prevented through a 'cocooned:before-insert' event handler
590
+ if (!this._notify('before-insert', event)) {
591
+ return false
306
592
  }
307
- self.notify(triggerNode, 'after-remove', eventData);
308
- }, timeout);
309
- },
310
-
311
- hideMarkedForDestruction: function () {
312
- var self = this;
313
- $(this.selector('remove', '&.existing.destroyed'), this.container).each(function (i, removeLink) {
314
- var node = self.findItem(removeLink);
315
- node.hide();
593
+
594
+ this._insert();
595
+ this._notify('after-insert', event);
596
+ }
597
+ }
598
+
599
+ /* Protected and private attributes and methods */
600
+ #item
601
+ #options = {
602
+ count: 1
603
+ // Other expected options:
604
+ // builder: A Builder instance
605
+ // method: Insertion method (one of: append, prepend, before, after, replaceWith)
606
+ // node: Insertion Node as a DOM Element
607
+ }
608
+
609
+ get _item () {
610
+ return this.#item
611
+ }
612
+
613
+ get _notified () {
614
+ return this.#options.node
615
+ }
616
+
617
+ _insert () {
618
+ this.#options.node[this.#options.method](this._item);
619
+ }
620
+
621
+ _build () {
622
+ return this.#options.builder.build(uniqueId()).firstElementChild
623
+ }
624
+ }
625
+
626
+ class Remove extends Trigger {
627
+ handle (event) {
628
+ // Removal can be prevented through a 'cocooned:before-remove' event handler
629
+ if (!this._notify('before-remove', event)) {
630
+ return false
631
+ }
632
+
633
+ this._hide(this._item).then(() => {
634
+ this._remove();
635
+ this._notify('after-remove', event);
316
636
  });
317
637
  }
638
+
639
+ /* Protected and private attributes and methods */
640
+ #notified
641
+
642
+ // Dynamic nodes are plainly removed from document, so we need to trigger
643
+ // events on their parent and memoize it so we still can find it after removal
644
+ get _notified () {
645
+ if (typeof this.#notified === 'undefined') {
646
+ this.#notified = this._item.parentElement;
647
+ }
648
+
649
+ return this.#notified
650
+ }
651
+
652
+ _remove () {
653
+ this._removable() ? this._item.remove() : this._markForDestruction();
654
+ }
655
+
656
+ _removable () {
657
+ return this._trigger.matches('.dynamic') ||
658
+ ('cocoonedPersisted' in this._trigger.dataset && this._trigger.dataset.cocoonedPersisted === 'false')
659
+ }
660
+
661
+ _markForDestruction () {
662
+ this._item.querySelector('input[type=hidden][name$="[_destroy]"]').setAttribute('value', 'true');
663
+ this._item.querySelectorAll('input[required], select[required]')
664
+ .forEach(input => input.removeAttribute('required'));
665
+ }
666
+ }
667
+
668
+ function clickHandler$1 (callback) {
669
+ return e => {
670
+ e.preventDefault();
671
+ callback(e);
672
+ }
673
+ }
674
+
675
+ function delegatedClickHandler (selector, callback) {
676
+ return e => {
677
+ const { target } = e;
678
+ if (!target.matches(selector)) {
679
+ return
680
+ }
681
+
682
+ e.preventDefault();
683
+ callback(e);
684
+ }
685
+ }
686
+
687
+ const coreMixin = (Base) => class extends Base {
688
+ static get selectors () {
689
+ return {
690
+ ...super.selectors,
691
+ 'triggers.add': ['[data-cocooned-trigger="add"]', '.cocooned-add'],
692
+ 'triggers.remove': ['[data-cocooned-trigger="remove"]', '.cocooned-remove']
693
+ }
694
+ }
695
+
696
+ start () {
697
+ super.start();
698
+
699
+ this.addTriggers = Array.from(this.container.ownerDocument.querySelectorAll(this._selector('triggers.add')))
700
+ .map(element => Add.create(element, this))
701
+ .filter(trigger => this.toContainer(trigger.insertionNode) === this.container);
702
+
703
+ this.addTriggers.forEach(add => add.trigger.addEventListener(
704
+ 'click',
705
+ clickHandler$1((e) => add.handle(e))
706
+ ));
707
+
708
+ this.container.addEventListener(
709
+ 'click',
710
+ delegatedClickHandler(this._selector('triggers.remove'), (e) => {
711
+ const trigger = new Remove(e.target, this);
712
+ trigger.handle(e);
713
+ })
714
+ );
715
+ }
318
716
  };
319
717
 
320
- Cocooned.Plugins.Limit = {
718
+ let Cocooned$1 = class Cocooned extends coreMixin(Base) {
719
+ static create (container, options) {
720
+ if ('cocoonedUuid' in container.dataset) {
721
+ return Cocooned.getInstance(container.dataset.cocoonedUuid)
722
+ }
321
723
 
322
- defaultOptionValue: false,
724
+ const cocooned = new this.constructor(container, options);
725
+ cocooned.start();
726
+
727
+ return cocooned
728
+ }
729
+
730
+ static start () {
731
+ document.querySelectorAll('[data-cocooned-container], [data-cocooned-options]')
732
+ .forEach(element => this.constructor.create(element));
733
+ }
734
+ };
323
735
 
324
- bindLimit: function () {
325
- this.limit = this.options['limit'];
326
- this.container.on('cocooned:before-insert', function (e) {
327
- var cocooned = e.cocooned;
328
- if (cocooned.getLength() < cocooned.limit) {
329
- return;
736
+ const limitMixin = (Base) => class extends Base {
737
+ static get defaultOptions () {
738
+ return { ...super.defaultOptions, ...{ limit: false } }
739
+ }
740
+
741
+ start () {
742
+ super.start();
743
+ if (this.options.limit === false) {
744
+ return
745
+ }
746
+
747
+ this.container.addEventListener('cocooned:before-insert', e => {
748
+ if (this.items.length < this.options.limit) {
749
+ return
330
750
  }
331
751
 
332
- e.stopPropagation();
333
- var eventData = { link: e.link, node: e.node, cocooned: cocooned, originalEvent: e };
334
- cocooned.notify(cocooned.container, 'limit-reached', eventData);
752
+ e.preventDefault();
753
+ this.notify(this.container, 'limit-reached', e.detail);
335
754
  });
336
- },
337
-
338
- getLength: function () {
339
- return this.getItems('&:visible').length;
340
755
  }
341
756
  };
342
757
 
343
- Cocooned.Plugins.Reorderable = {
344
-
345
- defaultOptionValue: false,
346
- defaultConfig: { startAt: 1 },
347
-
348
- normalizeConfig: function(config) {
349
- if (typeof config === 'boolean' && config) {
350
- return this.defaultConfig;
351
- }
352
- return config;
353
- },
354
-
355
- bindReorderable: function () {
356
- var self = this;
357
-
358
- // Maintain indexes
359
- this.container
360
- .on('cocooned:after-insert', function (e) { self.reindex(e); })
361
- .on('cocooned:after-remove', function (e) { self.reindex(e); })
362
- .on('cocooned:after-move', function (e) { self.reindex(e); });
363
-
364
- // Move items
365
- this.container.on(
366
- this.namespacedNativeEvents('click'),
367
- [this.selector('up'), this.selector('down')].join(', '),
368
- function (e) {
369
- e.preventDefault();
370
- e.stopPropagation();
371
-
372
- var node = this;
373
- var up = self.classes['up'].some(function (klass) {
374
- return node.className.indexOf(klass) !== -1;
375
- });
376
- self.move(this, up ? 'up' : 'down', e);
377
- });
378
-
379
- // Ensure positions are unique before save
380
- this.container.closest('form').on(
381
- this.namespacedNativeEvents('submit'),
382
- function (e) {
383
- self.reindex(e);
384
- });
385
- },
386
-
387
- move: function (moveLink, direction, originalEvent) {
388
- var self = this;
389
- var $mover = $(moveLink);
390
- var node = $mover.closest(this.selector('item'));
391
- var siblings = (direction === 'up'
392
- ? node.prevAll(this.selector('item', '&:eq(0)'))
393
- : node.nextAll(this.selector('item', '&:eq(0)')));
394
-
395
- if (siblings.length === 0) {
396
- return;
397
- }
398
-
399
- // Move can be prevented through a 'cocooned:before-move' event handler
400
- var eventData = { link: $mover, node: node, cocooned: this, originalEvent: originalEvent };
401
- if (!self.notify(node, 'before-move', eventData)) {
402
- return false;
403
- }
404
-
405
- var height = self.container.outerHeight();
406
- var width = self.container.outerWidth();
407
-
408
- self.container.css('height', height).css('width', width);
409
- self.hide(node, function () {
410
- var movedNode = $(this).detach();
411
- movedNode[(direction === 'up' ? 'insertBefore' : 'insertAfter')](siblings);
412
-
413
- self.show(movedNode, function () {
414
- self.container.css('height', '').css('width', ''); // Object notation does not work here.
415
- self.notify(movedNode, 'after-move', eventData);
416
- });
758
+ class Move extends Trigger {
759
+ handle (event) {
760
+ if (this._pivotItem === null) {
761
+ return
762
+ }
763
+
764
+ // Moves can be prevented through a 'cocooned:before-move' event handler
765
+ if (!this._notify('before-move', event)) {
766
+ return false
767
+ }
768
+
769
+ this._hide(this._item).then(() => {
770
+ this._move();
771
+ this._show(this._item).then(() => this._notify('after-move', event));
417
772
  });
418
- },
773
+ }
419
774
 
420
- reindex: function (originalEvent) {
421
- var i = this.options.reorderable.startAt;
422
- var nodes = this.getItems('&:visible');
423
- var eventData = { link: null, nodes: nodes, cocooned: this, originalEvent: originalEvent };
775
+ /* Protected and private attributes and methods */
776
+ get _pivotItem () {
777
+ throw new TypeError('_pivotItem() must be defined in subclasses')
778
+ }
779
+
780
+ _move () {
781
+ throw new TypeError('_move() must be defined in subclasses')
782
+ }
783
+
784
+ _findPivotItem (origin, method) {
785
+ let sibling = origin;
786
+
787
+ do {
788
+ sibling = sibling[method];
789
+ if (sibling !== null && this._cocooned.contains(sibling)) {
790
+ break
791
+ }
792
+ } while (sibling !== null)
793
+
794
+ return sibling
795
+ }
796
+ }
797
+
798
+ class Up extends Move {
799
+ /* Protected and private attributes and methods */
800
+ #pivotItem
801
+
802
+ get _pivotItem () {
803
+ if (typeof this.#pivotItem === 'undefined') {
804
+ this.#pivotItem = this._findPivotItem(this._item, 'previousElementSibling');
805
+ }
806
+
807
+ return this.#pivotItem
808
+ }
809
+
810
+ _move () {
811
+ this._pivotItem.before(this._item);
812
+ }
813
+ }
814
+
815
+ class Down extends Move {
816
+ /* Protected and private attributes and methods */
817
+ #pivotItem
818
+
819
+ get _pivotItem () {
820
+ if (typeof this.#pivotItem === 'undefined') {
821
+ this.#pivotItem = this._findPivotItem(this._item, 'nextElementSibling');
822
+ }
823
+
824
+ return this.#pivotItem
825
+ }
826
+
827
+ _move () {
828
+ this._pivotItem.after(this._item);
829
+ }
830
+ }
831
+
832
+ class Reindexer {
833
+ constructor (cocooned, startAt = 0) {
834
+ this.#cocooned = cocooned;
835
+ this.#startAt = startAt;
836
+ }
424
837
 
838
+ reindex (event) {
425
839
  // Reindex can be prevented through a 'cocooned:before-reindex' event handler
426
- if (!this.notify(this.container, 'before-reindex', eventData)) {
427
- return false;
840
+ if (!this.#notify('before-reindex', event)) {
841
+ return false
428
842
  }
429
843
 
430
- nodes.each(function () { $('input[id$=_position]', this).val(i++); });
431
- this.notify(this.container, 'after-reindex', eventData);
432
- },
844
+ this.#positionFields.forEach((field, i) => field.setAttribute('value', i + this.#startAt));
845
+ this.#notify('after-reindex', event);
846
+ }
433
847
 
434
- show: function (node, callback) {
435
- callback = callback || function () { return true; };
848
+ /* Protected and private attributes and methods */
849
+ #cocooned
850
+ #startAt
436
851
 
437
- node.addClass('cocooned-visible-item');
438
- setTimeout(function () {
439
- callback.apply(node);
440
- node.removeClass('cocooned-hidden-item');
441
- }, 500);
442
- },
852
+ get #positionFields () {
853
+ return this.#nodes.map(node => node.querySelector('input[name$="[position]"]'))
854
+ }
443
855
 
444
- hide: function (node, callback) {
445
- node.removeClass('cocooned-visible-item').addClass('cocooned-hidden-item');
446
- if (callback) {
447
- setTimeout(function () {
448
- callback.apply(node);
449
- }, 500);
856
+ get #nodes () {
857
+ return this.#cocooned.items
858
+ }
859
+
860
+ #notify (eventName, originalEvent) {
861
+ return this.#cocooned.notify(this.#cocooned.container, eventName, this.#eventData(originalEvent))
862
+ }
863
+
864
+ #eventData (originalEvent) {
865
+ return { nodes: this.#nodes, cocooned: this.#cocooned, originalEvent }
866
+ }
867
+ }
868
+
869
+ function clickHandler (selector, cocooned, TriggerClass) {
870
+ return delegatedClickHandler(selector, (e) => {
871
+ const trigger = new TriggerClass(e.target, cocooned);
872
+ trigger.handle(e);
873
+ })
874
+ }
875
+
876
+ const reorderableMixin = (Base) => class extends Base {
877
+ static get defaultOptions () {
878
+ return { ...super.defaultOptions, ...{ reorderable: false } }
879
+ }
880
+
881
+ static get selectors () {
882
+ return {
883
+ ...super.selectors,
884
+ 'triggers.up': ['[data-cocooned-trigger="up"]', '.cocooned-move-up'],
885
+ 'triggers.down': ['[data-cocooned-trigger="down"]', '.cocooned-move-down']
450
886
  }
451
887
  }
452
- };
453
888
 
454
- // Expose a jQuery plugin
455
- $.fn.cocooned = function (options) {
456
- return this.each(function () {
457
- var container = $(this);
458
- if (typeof container.data('cocooned') !== 'undefined') {
459
- return;
889
+ start () {
890
+ super.start();
891
+ if (this.options.reorderable === false) {
892
+ return
460
893
  }
461
894
 
462
- var opts = options;
463
- if (typeof container.data('cocooned-options') !== 'undefined') {
464
- opts = $.extend(opts, container.data('cocooned-options'));
895
+ this.container.addEventListener('cocooned:after-insert', e => this._reindexer.reindex(e));
896
+ this.container.addEventListener('cocooned:after-remove', e => this._reindexer.reindex(e));
897
+ this.container.addEventListener('cocooned:after-move', e => this._reindexer.reindex(e));
898
+ const form = this.container.closest('form');
899
+ if (form !== null) {
900
+ form.addEventListener('submit', e => this._reindexer.reindex(e));
465
901
  }
466
902
 
467
- var cocooned = new Cocooned(container, opts);
468
- container.data('cocooned', cocooned);
469
- });
903
+ this.container.addEventListener('click', clickHandler(this._selector('triggers.up'), this, Up));
904
+ this.container.addEventListener('click', clickHandler(this._selector('triggers.down'), this, Down));
905
+ }
906
+
907
+ /* Protected and private attributes and methods */
908
+ static _normalizeOptions (options) {
909
+ const normalized = super._normalizeOptions(options);
910
+ if (typeof normalized.reorderable === 'boolean' && normalized.reorderable) {
911
+ normalized.reorderable = { startAt: 1 };
912
+ }
913
+
914
+ return normalized
915
+ }
916
+
917
+ #reindexer
918
+
919
+ get _reindexer () {
920
+ if (typeof this.#reindexer === 'undefined') {
921
+ this.#reindexer = new Reindexer(this, this.options.reorderable.startAt);
922
+ }
923
+
924
+ return this.#reindexer
925
+ }
926
+ };
927
+
928
+ const cocoonSupportMixin = (Base) => class extends Base {
929
+ static get eventNamespaces () {
930
+ return [...super.eventNamespaces, 'cocoon']
931
+ }
932
+
933
+ static get selectors () {
934
+ const selectors = super.selectors;
935
+ selectors.item.push('.nested-fields');
936
+ selectors['triggers.add'].push('.add_fields');
937
+ selectors['triggers.remove'].push('.remove_fields');
938
+
939
+ return selectors
940
+ }
941
+ };
942
+
943
+ const findInsertionNode = function (trigger, $) {
944
+ const insertionNode = trigger.data('association-insertion-node');
945
+ const insertionTraversal = trigger.data('association-insertion-traversal');
946
+
947
+ if (!insertionNode) return trigger.parent()
948
+ if (typeof insertionNode === 'function') return insertionNode(trigger)
949
+ if (insertionTraversal) return trigger[insertionTraversal](insertionNode)
950
+ return insertionNode === 'this' ? trigger : $(insertionNode)
951
+ };
952
+
953
+ const findContainer = function (trigger, $) {
954
+ const $trigger = $(trigger);
955
+ const insertionNode = findInsertionNode($trigger, $);
956
+ const insertionMethod = $trigger.data('association-insertion-method') || 'before';
957
+
958
+ if (['before', 'after', 'replaceWith'].includes(insertionMethod)) return insertionNode.parent()
959
+ return insertionNode
960
+ };
961
+
962
+ const cocoonAutoStart = function (jQuery) {
963
+ jQuery('.add_fields')
964
+ .map((_i, adder) => findContainer(adder, jQuery))
965
+ .each((_i, container) => jQuery(container).cocooned());
470
966
  };
471
967
 
968
+ class Cocooned extends reorderableMixin(limitMixin(cocoonSupportMixin(Cocooned$1))) {
969
+ static create (container, options = {}) {
970
+ if ('cocoonedUuid' in container.dataset) {
971
+ return Cocooned.getInstance(container.dataset.cocoonedUuid)
972
+ }
973
+
974
+ const cocooned = new Cocooned(container, options);
975
+ cocooned.start();
976
+
977
+ return cocooned
978
+ }
979
+
980
+ static start () {
981
+ document.querySelectorAll('[data-cocooned-container], [data-cocooned-options]')
982
+ .forEach(element => Cocooned.create(element));
983
+ }
984
+ }
985
+
986
+ const jQueryPluginMixin = function (jQuery, Cocooned) {
987
+ jQuery.fn.cocooned = function (options) {
988
+ return this.each((_i, el) => Cocooned.create(el, options))
989
+ };
990
+ };
991
+
992
+ /* global jQuery, $ */
993
+
994
+ // Expose a jQuery plugin
995
+ jQueryPluginMixin(jQuery, Cocooned);
996
+
472
997
  // On-load initialization
473
- $(function () {
474
- $('*[data-cocooned-options]').each(function (i, el) {
475
- $(el).cocooned();
476
- });
477
- });
998
+ const cocoonedAutoStart = () => Cocooned.start();
999
+ $(cocoonedAutoStart);
1000
+
1001
+ $(() => cocoonAutoStart($));
1002
+
1003
+ deprecator('3.0').warn(
1004
+ 'Loading @notus.sh/cocooned/cocooned is deprecated',
1005
+ '@notus.sh/cocooned/jquery, @notus.sh/cocooned or `@notus.sh/cocooned/src/cocooned/cocooned`'
1006
+ );
478
1007
 
479
1008
  return Cocooned;
1009
+
480
1010
  }));