cocooned 1.4.1 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
  }));