hotwire_nested_form 1.3.0 → 1.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: de50acd45ca73b2f450facf4306904957892b14438c12e4b64aac7fa02eed9e9
4
- data.tar.gz: c3e7e5096d346ba91d27f61ce6c20c28f3a076763769ab1e895b89df943a081d
3
+ metadata.gz: 7033260639294c3125ab0debe416848833f6b55b04bf9f826ff6686f4d30c6c4
4
+ data.tar.gz: 6c93778b96b4b2fb757d84f71d1999a559c725528ee5835ed4e6906f1eb77366
5
5
  SHA512:
6
- metadata.gz: ead6225736617ac2c6367effc43422804167929c0d5fca1962165627e89d589c8c001f0e476f96eee2c8050de0d765863fe7bc8aa3b48a9f027ef7f9eb3ca034
7
- data.tar.gz: cde5e3da91c82bd35ea3813f45a576b9ad872fbd1b806dcd35df6830e49597f6c34288d3bc3ec780d9bc2da946be7f68a01fd5021fe6efe59a60d2876f1f59bd
6
+ metadata.gz: 12f334f31a6b1e02abb519f1f780618c4749b95673c69fb59d4f65f47af74569d4a3688349d7f3513b4330d83b1caf5ec7e22af15737a04316be09e06c8cbff9
7
+ data.tar.gz: 981f73f9d25e634be708d81ee3e6e3f08569db17d4c269854b92ca045fbd641f5811294612bc75ea3d2d6b3ca86733862d3e61cb04d31d853acf2be917eae651
data/CHANGELOG.md CHANGED
@@ -7,6 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.5.0] - 2026-02-06
11
+
12
+ ### Added
13
+ - Accessibility (a11y) support enabled by default
14
+ - `data-nested-form-a11y-value` - enable/disable accessibility features (default: `true`)
15
+ - `role="group"` and `aria-label` automatically set on controller element
16
+ - Live region (`aria-live="polite"`) for screen reader announcements
17
+ - Focus management: first input focused after add, add button focused after remove
18
+ - Announcements: "Item N added.", "Item removed. N remaining.", "Item duplicated."
19
+ - Duplicate/Clone nested items
20
+ - `link_to_duplicate_association` Ruby helper
21
+ - `nested-form#duplicate` Stimulus action
22
+ - Clones item with field values, generates new index, clears persisted record ID
23
+ - Respects max limit
24
+ - New events: `nested-form:before-duplicate` (cancelable), `nested-form:after-duplicate`
25
+
26
+ ## [1.4.0] - 2026-02-06
27
+
28
+ ### Added
29
+ - Add/remove animations with CSS transitions
30
+ - `data-nested-form-animation-value` - animation type: `"fade"`, `"slide"`, or `""` (none)
31
+ - `data-nested-form-animation-duration-value` - duration in ms (default: 300)
32
+ - CSS classes: `nested-form-enter`, `nested-form-enter-active`, `nested-form-exit-active`
33
+ - Optional animation stylesheet: `hotwire_nested_form/animations.css`
34
+ - Install with: `rails g hotwire_nested_form:install --animations`
35
+ - NPM users: `import "hotwire-nested-form-stimulus/css/animations.css"`
36
+ - Deep nesting (multi-level nested forms)
37
+ - Association-specific placeholders (`NEW_TASK_RECORD`, `NEW_SUBTASK_RECORD`) prevent collisions
38
+ - Each `link_to_add_association` automatically generates unique placeholders per association
39
+ - `<template>` tags for template storage (replaces `data-template` attribute for reliable deep nesting)
40
+ - Full backward compatibility - single-level forms work unchanged
41
+
42
+ ### Changed
43
+ - Template HTML now stored in `<template>` tags instead of `data-template` attributes
44
+ - `link_to_add_association` outputs `<template>` + `<a>` tag pair
45
+ - Controller `remove()` refactored into `remove()` + `removeElement()` for animation support
46
+ - Added `getTemplate()` method for flexible template lookup
47
+
10
48
  ## [1.3.0] - 2026-02-06
11
49
 
12
50
  ### Added
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- hotwire_nested_form (1.3.0)
4
+ hotwire_nested_form (1.5.0)
5
5
  rails (>= 7.0)
6
6
 
7
7
  GEM
data/README.md CHANGED
@@ -272,6 +272,200 @@ params.require(:project).permit(:name,
272
272
  }
273
273
  ```
274
274
 
275
+ ## Animations
276
+
277
+ Add smooth CSS transitions when items are added or removed:
278
+
279
+ ```erb
280
+ <div data-controller="nested-form"
281
+ data-nested-form-animation-value="fade"
282
+ data-nested-form-animation-duration-value="300">
283
+ <!-- nested fields -->
284
+ </div>
285
+ ```
286
+
287
+ ### Include the Animation Stylesheet
288
+
289
+ **Rails (generator):**
290
+ ```bash
291
+ rails g hotwire_nested_form:install --animations
292
+ ```
293
+
294
+ **Rails (manual):** Add to your stylesheet:
295
+ ```css
296
+ @import "hotwire_nested_form/animations";
297
+ ```
298
+
299
+ **NPM:**
300
+ ```javascript
301
+ import "hotwire-nested-form-stimulus/css/animations.css"
302
+ ```
303
+
304
+ ### Animation Options
305
+
306
+ | Attribute | Default | Description |
307
+ |-----------|---------|-------------|
308
+ | `data-nested-form-animation-value` | `""` | `"fade"`, `"slide"`, or `""` (none) |
309
+ | `data-nested-form-animation-duration-value` | `300` | Duration in milliseconds |
310
+
311
+ ### CSS Classes
312
+
313
+ | Class | When Applied |
314
+ |-------|-------------|
315
+ | `nested-form-enter` | Immediately on add |
316
+ | `nested-form-enter-active` | Next frame after add (triggers transition) |
317
+ | `nested-form-exit-active` | On remove (triggers transition, then element is hidden/removed) |
318
+
319
+ You can customize the animations by overriding these classes in your stylesheet.
320
+
321
+ ## Deep Nesting (Multi-Level)
322
+
323
+ Nest forms inside forms (e.g. Project -> Tasks -> Subtasks):
324
+
325
+ ### 1. Model Setup
326
+
327
+ ```ruby
328
+ class Project < ApplicationRecord
329
+ has_many :tasks, dependent: :destroy
330
+ accepts_nested_attributes_for :tasks, allow_destroy: true
331
+ end
332
+
333
+ class Task < ApplicationRecord
334
+ belongs_to :project
335
+ has_many :subtasks, dependent: :destroy
336
+ accepts_nested_attributes_for :subtasks, allow_destroy: true
337
+ end
338
+ ```
339
+
340
+ ### 2. Form Setup
341
+
342
+ ```erb
343
+ <%# _form.html.erb %>
344
+ <%= form_with model: @project do |f| %>
345
+ <div data-controller="nested-form">
346
+ <div id="tasks">
347
+ <%= f.fields_for :tasks do |tf| %>
348
+ <%= render "task_fields", f: tf %>
349
+ <% end %>
350
+ </div>
351
+ <%= link_to_add_association "Add Task", f, :tasks,
352
+ insertion: :append, target: "#tasks" %>
353
+ </div>
354
+ <%= f.submit %>
355
+ <% end %>
356
+
357
+ <%# _task_fields.html.erb %>
358
+ <div class="nested-fields">
359
+ <%= f.text_field :name %>
360
+ <%= link_to_remove_association "Remove Task", f %>
361
+
362
+ <div data-controller="nested-form">
363
+ <div id="subtasks">
364
+ <%= f.fields_for :subtasks do |sf| %>
365
+ <%= render "subtask_fields", f: sf %>
366
+ <% end %>
367
+ </div>
368
+ <%= link_to_add_association "Add Subtask", f, :subtasks,
369
+ insertion: :append, target: "#subtasks" %>
370
+ </div>
371
+ </div>
372
+
373
+ <%# _subtask_fields.html.erb %>
374
+ <div class="nested-fields">
375
+ <%= f.text_field :name %>
376
+ <%= link_to_remove_association "Remove", f %>
377
+ </div>
378
+ ```
379
+
380
+ ### 3. Controller Params
381
+
382
+ ```ruby
383
+ def project_params
384
+ params.require(:project).permit(:name,
385
+ tasks_attributes: [:id, :name, :_destroy,
386
+ subtasks_attributes: [:id, :name, :_destroy]])
387
+ end
388
+ ```
389
+
390
+ Each nesting level automatically gets a unique placeholder (`NEW_TASK_RECORD`, `NEW_SUBTASK_RECORD`) so adding items at one level doesn't affect templates at other levels. Each `data-controller="nested-form"` operates independently.
391
+
392
+ ## Accessibility
393
+
394
+ Accessibility features are **enabled by default**. The controller automatically:
395
+
396
+ - Sets `role="group"` and `aria-label` on the controller element
397
+ - Creates an `aria-live="polite"` region for screen reader announcements
398
+ - Focuses the first input when a new item is added
399
+ - Moves focus to the "Add" button when an item is removed
400
+ - Announces add/remove/duplicate actions to screen readers
401
+
402
+ ### Disabling Accessibility
403
+
404
+ ```erb
405
+ <div data-controller="nested-form"
406
+ data-nested-form-a11y-value="false">
407
+ <!-- fields -->
408
+ </div>
409
+ ```
410
+
411
+ ### Custom ARIA Label
412
+
413
+ Set your own `aria-label` and the controller will preserve it:
414
+
415
+ ```erb
416
+ <div data-controller="nested-form"
417
+ aria-label="Project tasks">
418
+ <!-- fields -->
419
+ </div>
420
+ ```
421
+
422
+ ## Duplicate/Clone
423
+
424
+ Duplicate an existing nested item (with its field values) as a starting point for a new item:
425
+
426
+ ### 1. Add Duplicate Button to Partial
427
+
428
+ ```erb
429
+ <%# _task_fields.html.erb %>
430
+ <div class="nested-fields">
431
+ <%= f.text_field :name %>
432
+ <%= link_to_duplicate_association "Duplicate", f, class: "btn-sm" %>
433
+ <%= link_to_remove_association "Remove", f %>
434
+ </div>
435
+ ```
436
+
437
+ ### 2. That's It!
438
+
439
+ Clicking "Duplicate" will:
440
+ - Clone the item with all current field values
441
+ - Generate a new unique index (so Rails creates a new record)
442
+ - Remove the persisted record ID (so it saves as a new record)
443
+ - Respect the max limit
444
+ - Animate the new item if animations are enabled
445
+ - Focus the first input of the clone
446
+ - Announce "Item duplicated." to screen readers
447
+
448
+ ### Duplicate Events
449
+
450
+ | Event | Cancelable | Detail |
451
+ |-------|------------|--------|
452
+ | `nested-form:before-duplicate` | Yes | `{ source }` |
453
+ | `nested-form:after-duplicate` | No | `{ source, clone }` |
454
+
455
+ ```javascript
456
+ // Customize the clone before it's inserted
457
+ document.addEventListener("nested-form:after-duplicate", (event) => {
458
+ const clone = event.detail.clone
459
+ // Clear specific fields in the clone
460
+ clone.querySelector("input[name*='description']").value = ""
461
+ })
462
+
463
+ // Prevent duplication conditionally
464
+ document.addEventListener("nested-form:before-duplicate", (event) => {
465
+ if (someCondition) event.preventDefault()
466
+ })
467
+ ```
468
+
275
469
  ## NPM Package (JavaScript-only)
276
470
 
277
471
  For non-Rails projects using Stimulus, install via npm:
@@ -358,6 +552,31 @@ link_to_remove_association(name, form, options = {}, &block)
358
552
  <% end %>
359
553
  ```
360
554
 
555
+ ### link_to_duplicate_association
556
+
557
+ ```ruby
558
+ link_to_duplicate_association(name, form, options = {}, &block)
559
+ ```
560
+
561
+ | Option | Type | Default | Description |
562
+ |--------|------|---------|-------------|
563
+ | (standard HTML options) | | | Passed to the `<a>` tag |
564
+
565
+ **Examples:**
566
+
567
+ ```erb
568
+ <%# Basic usage %>
569
+ <%= link_to_duplicate_association "Duplicate", f %>
570
+
571
+ <%# With HTML classes %>
572
+ <%= link_to_duplicate_association "Duplicate", f, class: "btn btn-sm" %>
573
+
574
+ <%# With block %>
575
+ <%= link_to_duplicate_association f do %>
576
+ <span class="icon">📋</span> Copy
577
+ <% end %>
578
+ ```
579
+
361
580
  ## JavaScript Events
362
581
 
363
582
  | Event | Cancelable | Detail | When |
@@ -370,6 +589,8 @@ link_to_remove_association(name, form, options = {}, &block)
370
589
  | `nested-form:minimum-reached` | No | `{ minimum, current }` | When min limit reached |
371
590
  | `nested-form:before-sort` | Yes | `{ item, oldIndex }` | Before drag starts |
372
591
  | `nested-form:after-sort` | No | `{ item, oldIndex, newIndex }` | After drop completes |
592
+ | `nested-form:before-duplicate` | Yes | `{ source }` | Before duplicating item |
593
+ | `nested-form:after-duplicate` | No | `{ source, clone }` | After item duplicated |
373
594
 
374
595
  **Usage Examples:**
375
596
 
@@ -0,0 +1,47 @@
1
+ /*
2
+ * hotwire_nested_form - Animation Styles
3
+ *
4
+ * Include this stylesheet for smooth add/remove transitions.
5
+ * Enable with: data-nested-form-animation-value="fade" or "slide"
6
+ *
7
+ * Rails: Include in your application stylesheet or copy to app/assets/stylesheets/
8
+ * NPM: import "hotwire-nested-form/css/animations.css"
9
+ */
10
+
11
+ /* Fade animation (default) */
12
+ .nested-form-enter {
13
+ opacity: 0;
14
+ transform: translateY(-10px);
15
+ }
16
+
17
+ .nested-form-enter-active {
18
+ opacity: 1;
19
+ transform: translateY(0);
20
+ transition: opacity 300ms ease, transform 300ms ease;
21
+ }
22
+
23
+ .nested-form-exit-active {
24
+ opacity: 0;
25
+ transform: translateY(-10px);
26
+ transition: opacity 300ms ease, transform 300ms ease;
27
+ }
28
+
29
+ /* Slide animation */
30
+ .nested-form-enter[data-animation="slide"] {
31
+ max-height: 0;
32
+ overflow: hidden;
33
+ opacity: 0;
34
+ }
35
+
36
+ .nested-form-enter-active[data-animation="slide"] {
37
+ max-height: 500px;
38
+ opacity: 1;
39
+ transition: max-height 300ms ease, opacity 300ms ease;
40
+ }
41
+
42
+ .nested-form-exit-active[data-animation="slide"] {
43
+ max-height: 0;
44
+ overflow: hidden;
45
+ opacity: 0;
46
+ transition: max-height 300ms ease, opacity 300ms ease;
47
+ }
@@ -7,11 +7,21 @@ module HotwireNestedForm
7
7
 
8
8
  desc 'Install hotwire_nested_form'
9
9
 
10
+ class_option :animations, type: :boolean, default: false,
11
+ desc: 'Copy animation stylesheet'
12
+
10
13
  def copy_stimulus_controller
11
14
  copy_file 'nested_form_controller.js',
12
15
  'app/javascript/controllers/nested_form_controller.js'
13
16
  end
14
17
 
18
+ def copy_animation_stylesheet
19
+ return unless options[:animations]
20
+
21
+ copy_file 'animations.css',
22
+ 'app/assets/stylesheets/hotwire_nested_form/animations.css'
23
+ end
24
+
15
25
  def show_post_install_message
16
26
  say ''
17
27
  say '=' * 60
@@ -0,0 +1,47 @@
1
+ /*
2
+ * hotwire_nested_form - Animation Styles
3
+ *
4
+ * Include this stylesheet for smooth add/remove transitions.
5
+ * Enable with: data-nested-form-animation-value="fade" or "slide"
6
+ *
7
+ * Rails: Include in your application stylesheet or copy to app/assets/stylesheets/
8
+ * NPM: import "hotwire-nested-form/css/animations.css"
9
+ */
10
+
11
+ /* Fade animation (default) */
12
+ .nested-form-enter {
13
+ opacity: 0;
14
+ transform: translateY(-10px);
15
+ }
16
+
17
+ .nested-form-enter-active {
18
+ opacity: 1;
19
+ transform: translateY(0);
20
+ transition: opacity 300ms ease, transform 300ms ease;
21
+ }
22
+
23
+ .nested-form-exit-active {
24
+ opacity: 0;
25
+ transform: translateY(-10px);
26
+ transition: opacity 300ms ease, transform 300ms ease;
27
+ }
28
+
29
+ /* Slide animation */
30
+ .nested-form-enter[data-animation="slide"] {
31
+ max-height: 0;
32
+ overflow: hidden;
33
+ opacity: 0;
34
+ }
35
+
36
+ .nested-form-enter-active[data-animation="slide"] {
37
+ max-height: 500px;
38
+ opacity: 1;
39
+ transition: max-height 300ms ease, opacity 300ms ease;
40
+ }
41
+
42
+ .nested-form-exit-active[data-animation="slide"] {
43
+ max-height: 0;
44
+ overflow: hidden;
45
+ opacity: 0;
46
+ transition: max-height 300ms ease, opacity 300ms ease;
47
+ }
@@ -8,16 +8,24 @@ export default class extends Controller {
8
8
  limitBehavior: { type: String, default: "disable" },
9
9
  sortable: { type: Boolean, default: false },
10
10
  positionField: { type: String, default: "position" },
11
- sortHandle: { type: String, default: "" }
11
+ sortHandle: { type: String, default: "" },
12
+ animation: { type: String, default: "" },
13
+ animationDuration: { type: Number, default: 300 },
14
+ a11y: { type: Boolean, default: true }
12
15
  }
13
16
 
14
17
  connect() {
18
+ if (this.a11yValue) this.setupAccessibility()
15
19
  this.updateButtonStates()
16
20
  if (this.sortableValue) this.initializeSortable()
17
21
  }
18
22
 
19
23
  disconnect() {
20
24
  if (this.sortableInstance) this.sortableInstance.destroy()
25
+ if (this.liveRegion) {
26
+ this.liveRegion.remove()
27
+ this.liveRegion = null
28
+ }
21
29
  }
22
30
 
23
31
  get currentCount() {
@@ -42,7 +50,7 @@ export default class extends Controller {
42
50
  return
43
51
  }
44
52
 
45
- const template = event.currentTarget.dataset.template
53
+ const template = this.getTemplate(event.currentTarget)
46
54
  const insertion = event.currentTarget.dataset.insertion || "before"
47
55
  const targetSelector = event.currentTarget.dataset.target
48
56
  const count = parseInt(event.currentTarget.dataset.count) || 1
@@ -76,6 +84,14 @@ export default class extends Controller {
76
84
 
77
85
  if (beforeEvent.defaultPrevented) return
78
86
 
87
+ if (this.animationValue) {
88
+ this.animateOut(wrapper, () => this.removeElement(wrapper))
89
+ } else {
90
+ this.removeElement(wrapper)
91
+ }
92
+ }
93
+
94
+ removeElement(wrapper) {
79
95
  const destroyInput = wrapper.querySelector("input[name*='_destroy']")
80
96
 
81
97
  if (destroyInput) {
@@ -87,11 +103,31 @@ export default class extends Controller {
87
103
 
88
104
  this.dispatch("after-remove", { detail: { wrapper } })
89
105
  this.updateButtonStates()
106
+
107
+ if (this.a11yValue) {
108
+ const addButton = this.element.querySelector('[data-action*="nested-form#add"]')
109
+ if (addButton) addButton.focus()
110
+ this.announce(`Item removed. ${this.currentCount} remaining.`)
111
+ }
112
+ }
113
+
114
+ getTemplate(trigger) {
115
+ // Prefer <template> tag (handles deep nesting), fall back to data-template
116
+ const placeholder = trigger.dataset.placeholder
117
+ if (placeholder) {
118
+ const templateEl = this.element.querySelector(
119
+ `template[data-nested-form-template="${placeholder}"]`
120
+ )
121
+ if (templateEl) return templateEl.innerHTML
122
+ }
123
+ return trigger.dataset.template
90
124
  }
91
125
 
92
126
  insertFields(template, insertion, targetSelector, trigger) {
93
127
  const newId = new Date().getTime()
94
- const content = template.replace(/NEW_RECORD/g, newId)
128
+ const placeholder = trigger.dataset.placeholder || "NEW_RECORD"
129
+ const regex = new RegExp(placeholder, "g")
130
+ const content = template.replace(regex, newId)
95
131
 
96
132
  const fragment = document.createRange().createContextualFragment(content)
97
133
  const wrapper = fragment.firstElementChild
@@ -122,6 +158,144 @@ export default class extends Controller {
122
158
  }
123
159
 
124
160
  this.dispatch("after-add", { detail: { wrapper } })
161
+
162
+ if (this.animationValue) {
163
+ this.animateIn(wrapper)
164
+ }
165
+
166
+ if (this.a11yValue) {
167
+ this.focusFirstInput(wrapper)
168
+ this.announce(`Item ${this.currentCount} added.`)
169
+ }
170
+ }
171
+
172
+ // Duplicate
173
+
174
+ duplicate(event) {
175
+ event.preventDefault()
176
+
177
+ if (this.currentCount >= this.maxValue) {
178
+ this.dispatch("limit-reached", {
179
+ detail: { limit: this.maxValue, current: this.currentCount }
180
+ })
181
+ return
182
+ }
183
+
184
+ const wrapper = event.currentTarget.closest(`.${this.wrapperClassValue}`)
185
+ if (!wrapper) return
186
+
187
+ const beforeEvent = this.dispatch("before-duplicate", {
188
+ cancelable: true,
189
+ detail: { source: wrapper }
190
+ })
191
+
192
+ if (beforeEvent.defaultPrevented) return
193
+
194
+ const clone = wrapper.cloneNode(true)
195
+ const newId = new Date().getTime()
196
+
197
+ this.prepareClone(clone, newId)
198
+
199
+ wrapper.after(clone)
200
+
201
+ this.dispatch("after-duplicate", {
202
+ detail: { source: wrapper, clone: clone }
203
+ })
204
+
205
+ if (this.animationValue) {
206
+ this.animateIn(clone)
207
+ }
208
+
209
+ this.updateButtonStates()
210
+ if (this.sortableValue) this.updatePositions()
211
+
212
+ if (this.a11yValue) {
213
+ this.focusFirstInput(clone)
214
+ this.announce("Item duplicated.")
215
+ }
216
+ }
217
+
218
+ prepareClone(clone, newId) {
219
+ const idInput = clone.querySelector("input[name*='[id]'][type='hidden']")
220
+ if (idInput) idInput.remove()
221
+
222
+ const destroyInput = clone.querySelector("input[name*='_destroy']")
223
+ if (destroyInput) destroyInput.value = "false"
224
+
225
+ const elements = clone.querySelectorAll("input, select, textarea, label")
226
+ elements.forEach(el => {
227
+ if (el.name) {
228
+ el.name = el.name.replace(/\[\d+\]/, `[${newId}]`)
229
+ }
230
+ if (el.id) {
231
+ el.id = el.id.replace(/_\d+_/, `_${newId}_`)
232
+ }
233
+ if (el.htmlFor) {
234
+ el.htmlFor = el.htmlFor.replace(/_\d+_/, `_${newId}_`)
235
+ }
236
+ })
237
+
238
+ clone.style.display = ""
239
+ }
240
+
241
+ // Accessibility
242
+
243
+ setupAccessibility() {
244
+ this.element.setAttribute("role", "group")
245
+ if (!this.element.getAttribute("aria-label")) {
246
+ this.element.setAttribute("aria-label", "Nested form fields")
247
+ }
248
+
249
+ this.liveRegion = document.createElement("div")
250
+ this.liveRegion.setAttribute("aria-live", "polite")
251
+ this.liveRegion.setAttribute("aria-atomic", "true")
252
+ this.liveRegion.classList.add("nested-form-live-region")
253
+ this.liveRegion.style.cssText = "position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)"
254
+ this.element.appendChild(this.liveRegion)
255
+ }
256
+
257
+ announce(message) {
258
+ if (!this.liveRegion) return
259
+ this.liveRegion.textContent = ""
260
+ requestAnimationFrame(() => {
261
+ this.liveRegion.textContent = message
262
+ })
263
+ }
264
+
265
+ focusFirstInput(wrapper) {
266
+ const focusable = wrapper.querySelector(
267
+ 'input:not([type="hidden"]), select, textarea'
268
+ )
269
+ if (focusable) {
270
+ setTimeout(() => focusable.focus(), 50)
271
+ }
272
+ }
273
+
274
+ // Animations
275
+
276
+ animateIn(element) {
277
+ element.classList.add("nested-form-enter")
278
+ requestAnimationFrame(() => {
279
+ requestAnimationFrame(() => {
280
+ element.classList.add("nested-form-enter-active")
281
+ setTimeout(() => {
282
+ element.classList.remove("nested-form-enter", "nested-form-enter-active")
283
+ }, this.animationDurationValue)
284
+ })
285
+ })
286
+ }
287
+
288
+ animateOut(element, callback) {
289
+ element.classList.add("nested-form-exit-active")
290
+
291
+ const done = () => {
292
+ element.classList.remove("nested-form-exit-active")
293
+ callback()
294
+ }
295
+
296
+ element.addEventListener("transitionend", done, { once: true })
297
+ // Fallback if transitionend doesn't fire
298
+ setTimeout(done, this.animationDurationValue + 50)
125
299
  }
126
300
 
127
301
  updateButtonStates() {
@@ -42,32 +42,42 @@ module HotwireNestedForm
42
42
  insertion = options.delete(:insertion) || :before
43
43
  target = options.delete(:target)
44
44
 
45
+ # Generate association-specific placeholder for deep nesting
46
+ placeholder = "NEW_#{association.to_s.upcase.singularize}_RECORD"
47
+
45
48
  # Build the template
46
49
  template = build_association_template(
47
50
  form,
48
51
  association,
49
52
  partial: partial,
50
53
  render_options: render_options,
51
- wrap_object: wrap_object
54
+ wrap_object: wrap_object,
55
+ placeholder: placeholder
52
56
  )
53
57
 
54
- # Build data attributes
58
+ # Build data attributes for the link
55
59
  data = options[:data] || {}
56
60
  data[:action] = 'nested-form#add'
57
- data[:template] = template
58
61
  data[:insertion] = insertion
59
62
  data[:count] = count if count > 1
60
63
  data[:target] = target if target
64
+ data[:placeholder] = placeholder
61
65
 
62
66
  options[:data] = data
63
67
  options[:href] = '#'
64
68
 
65
- content_tag(:a, name, options)
69
+ # Use <template> tag to store the HTML template (handles deep nesting)
70
+ # The template content is generated by Rails' fields_for/render, not user input
71
+ template_tag = content_tag(:template, template.html_safe, # rubocop:disable Rails/OutputSafety
72
+ data: { nested_form_template: placeholder })
73
+ link_tag = content_tag(:a, name, options)
74
+
75
+ template_tag + link_tag
66
76
  end
67
77
 
68
78
  private
69
79
 
70
- def build_association_template(form, association, partial:, render_options:, wrap_object:)
80
+ def build_association_template(form, association, partial:, render_options:, wrap_object:, placeholder:)
71
81
  # Get the association reflection
72
82
  reflection = form.object.class.reflect_on_association(association)
73
83
  raise ArgumentError, "Association #{association} not found" unless reflection
@@ -78,10 +88,8 @@ module HotwireNestedForm
78
88
  # Determine partial name
79
89
  partial_name = partial || "#{association.to_s.singularize}_fields"
80
90
 
81
- # Render the fields using fields_for
82
- # This works with both standard Rails FormBuilder and SimpleForm::FormBuilder
83
- # SimpleForm overrides fields_for to use simple_fields_for internally
84
- form.fields_for(association, new_object, child_index: 'NEW_RECORD') do |builder|
91
+ # Use association-specific placeholder for deep nesting support
92
+ form.fields_for(association, new_object, child_index: placeholder) do |builder|
85
93
  locals = (render_options[:locals] || {}).merge(f: builder)
86
94
  render(partial: partial_name, locals: locals)
87
95
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HotwireNestedForm
4
+ module Helpers
5
+ module DuplicateAssociation
6
+ # Generates a link to duplicate a nested form item
7
+ #
8
+ # @param name [String] Link text (or use block)
9
+ # @param form [FormBuilder] Nested form object
10
+ # @param options [Hash] HTML attributes
11
+ # @yield Block for custom link content
12
+ # @return [String] HTML link element
13
+ #
14
+ # @example Basic usage
15
+ # <%= link_to_duplicate_association "Duplicate", f %>
16
+ #
17
+ # @example With block
18
+ # <%= link_to_duplicate_association f do %>
19
+ # <span>Copy</span>
20
+ # <% end %>
21
+ #
22
+ def link_to_duplicate_association(name = nil, form = nil, options = {}, &)
23
+ if block_given?
24
+ options = form || {}
25
+ form = name
26
+ name = capture(&)
27
+ end
28
+
29
+ raise ArgumentError, 'form is required' unless form
30
+
31
+ options = options.dup
32
+
33
+ data = options[:data] || {}
34
+ data[:action] = 'nested-form#duplicate'
35
+
36
+ options[:data] = data
37
+ options[:href] = '#'
38
+
39
+ content_tag(:a, name, options)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -2,10 +2,12 @@
2
2
 
3
3
  require_relative 'helpers/add_association'
4
4
  require_relative 'helpers/remove_association'
5
+ require_relative 'helpers/duplicate_association'
5
6
 
6
7
  module HotwireNestedForm
7
8
  module Helpers
8
9
  include AddAssociation
9
10
  include RemoveAssociation
11
+ include DuplicateAssociation
10
12
  end
11
13
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HotwireNestedForm
4
- VERSION = '1.3.0'
4
+ VERSION = '1.5.0'
5
5
  end
data/npm/README.md CHANGED
@@ -30,23 +30,33 @@ application.register("nested-form", NestedFormController)
30
30
  <!-- Existing nested fields go here -->
31
31
  </div>
32
32
 
33
+ <template data-nested-form-template="NEW_ITEM_RECORD">
34
+ <div class="nested-fields">
35
+ <input name="items[NEW_ITEM_RECORD][name]">
36
+ <a href="#" data-action="nested-form#remove">Remove</a>
37
+ </div>
38
+ </template>
33
39
  <a href="#"
34
40
  data-action="nested-form#add"
35
- data-template="<div class='nested-fields'><input name='items[][name]'><a href='#' data-action='nested-form#remove'>Remove</a></div>">
41
+ data-placeholder="NEW_ITEM_RECORD"
42
+ data-insertion="append"
43
+ data-target="#items">
36
44
  Add Item
37
45
  </a>
38
46
  </div>
39
47
  ```
40
48
 
41
- ### Data Attributes
49
+ ### Data Attributes (on add button)
42
50
 
43
51
  | Attribute | Description | Default |
44
52
  |-----------|-------------|---------|
45
- | `data-template` | HTML template for new fields (use `NEW_RECORD` as placeholder) | Required |
53
+ | `data-placeholder` | Placeholder string in template to replace with unique ID | `"NEW_RECORD"` |
46
54
  | `data-insertion` | Where to insert: `before`, `after`, `append`, `prepend` | `before` |
47
55
  | `data-count` | Number of fields to add per click | `1` |
48
56
  | `data-target` | CSS selector for insertion container | Parent element |
49
57
 
58
+ **Note:** For backward compatibility, `data-template` (inline HTML) is still supported, but `<template>` tags are recommended for deep nesting support.
59
+
50
60
  ### Min/Max Limits
51
61
 
52
62
  ```html
@@ -98,6 +108,93 @@ window.Sortable = Sortable
98
108
  | `data-nested-form-position-field-value` | `"position"` | Position field name |
99
109
  | `data-nested-form-sort-handle-value` | (none) | Drag handle selector |
100
110
 
111
+ ### Animations
112
+
113
+ Add smooth CSS transitions when items are added or removed:
114
+
115
+ ```javascript
116
+ import "hotwire-nested-form-stimulus/css/animations.css"
117
+ ```
118
+
119
+ ```html
120
+ <div data-controller="nested-form"
121
+ data-nested-form-animation-value="fade"
122
+ data-nested-form-animation-duration-value="300">
123
+ <!-- fields here -->
124
+ </div>
125
+ ```
126
+
127
+ | Attribute | Default | Description |
128
+ |-----------|---------|-------------|
129
+ | `data-nested-form-animation-value` | `""` | `"fade"`, `"slide"`, or `""` (none) |
130
+ | `data-nested-form-animation-duration-value` | `300` | Duration in milliseconds |
131
+
132
+ ### Deep Nesting
133
+
134
+ For multi-level nesting, use `<template>` tags and `data-placeholder` attributes. Each nesting level needs its own `data-controller="nested-form"` and a unique placeholder:
135
+
136
+ ```html
137
+ <div data-controller="nested-form">
138
+ <div id="tasks">
139
+ <!-- task items here -->
140
+ </div>
141
+
142
+ <template data-nested-form-template="NEW_TASK_RECORD">
143
+ <div class="nested-fields">
144
+ <input name="items[tasks][NEW_TASK_RECORD][name]">
145
+
146
+ <!-- Nested level 2 -->
147
+ <div data-controller="nested-form">
148
+ <div id="subtasks"></div>
149
+ <template data-nested-form-template="NEW_SUBTASK_RECORD">
150
+ <div class="nested-fields">
151
+ <input name="items[tasks][NEW_TASK_RECORD][subtasks][NEW_SUBTASK_RECORD][name]">
152
+ <a href="#" data-action="nested-form#remove">Remove</a>
153
+ </div>
154
+ </template>
155
+ <a href="#" data-action="nested-form#add"
156
+ data-placeholder="NEW_SUBTASK_RECORD"
157
+ data-insertion="append" data-target="#subtasks">Add Subtask</a>
158
+ </div>
159
+ </div>
160
+ </template>
161
+ <a href="#" data-action="nested-form#add"
162
+ data-placeholder="NEW_TASK_RECORD"
163
+ data-insertion="append" data-target="#tasks">Add Task</a>
164
+ </div>
165
+ ```
166
+
167
+ The controller replaces only the matching placeholder per button, so nested templates stay intact.
168
+
169
+ ### Accessibility
170
+
171
+ Accessibility is **enabled by default**. The controller automatically:
172
+
173
+ - Sets `role="group"` and `aria-label` on the container
174
+ - Creates a live region for screen reader announcements
175
+ - Manages focus on add/remove/duplicate actions
176
+
177
+ Disable with:
178
+
179
+ ```html
180
+ <div data-controller="nested-form"
181
+ data-nested-form-a11y-value="false">
182
+ ```
183
+
184
+ ### Duplicate/Clone
185
+
186
+ Add a duplicate button to clone an existing item with its field values:
187
+
188
+ ```html
189
+ <div class="nested-fields">
190
+ <input name="items[][name]" value="Task A">
191
+ <a href="#" data-action="nested-form#duplicate">Duplicate</a>
192
+ <a href="#" data-action="nested-form#remove">Remove</a>
193
+ </div>
194
+ ```
195
+
196
+ The clone gets a new unique index and any persisted record ID is removed so it saves as a new record.
197
+
101
198
  ### Events
102
199
 
103
200
  | Event | Cancelable | Detail |
@@ -110,6 +207,8 @@ window.Sortable = Sortable
110
207
  | `nested-form:minimum-reached` | No | `{ minimum, current }` |
111
208
  | `nested-form:before-sort` | Yes | `{ item, oldIndex }` |
112
209
  | `nested-form:after-sort` | No | `{ item, oldIndex, newIndex }` |
210
+ | `nested-form:before-duplicate` | Yes | `{ source }` |
211
+ | `nested-form:after-duplicate` | No | `{ source, clone }` |
113
212
 
114
213
  ### Example: Listen for Events
115
214
 
@@ -0,0 +1,46 @@
1
+ /*
2
+ * hotwire-nested-form - Animation Styles
3
+ *
4
+ * Include this stylesheet for smooth add/remove transitions.
5
+ * Enable with: data-nested-form-animation-value="fade" or "slide"
6
+ *
7
+ * Import: import "hotwire-nested-form/css/animations.css"
8
+ */
9
+
10
+ /* Fade animation (default) */
11
+ .nested-form-enter {
12
+ opacity: 0;
13
+ transform: translateY(-10px);
14
+ }
15
+
16
+ .nested-form-enter-active {
17
+ opacity: 1;
18
+ transform: translateY(0);
19
+ transition: opacity 300ms ease, transform 300ms ease;
20
+ }
21
+
22
+ .nested-form-exit-active {
23
+ opacity: 0;
24
+ transform: translateY(-10px);
25
+ transition: opacity 300ms ease, transform 300ms ease;
26
+ }
27
+
28
+ /* Slide animation */
29
+ .nested-form-enter[data-animation="slide"] {
30
+ max-height: 0;
31
+ overflow: hidden;
32
+ opacity: 0;
33
+ }
34
+
35
+ .nested-form-enter-active[data-animation="slide"] {
36
+ max-height: 500px;
37
+ opacity: 1;
38
+ transition: max-height 300ms ease, opacity 300ms ease;
39
+ }
40
+
41
+ .nested-form-exit-active[data-animation="slide"] {
42
+ max-height: 0;
43
+ overflow: hidden;
44
+ opacity: 0;
45
+ transition: max-height 300ms ease, opacity 300ms ease;
46
+ }
data/npm/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "hotwire-nested-form-stimulus",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Stimulus controller for dynamic nested forms - works with Rails, React, Vue, or any Stimulus app",
5
5
  "main": "src/index.js",
6
6
  "module": "src/index.js",
7
7
  "type": "module",
8
8
  "files": [
9
- "src"
9
+ "src",
10
+ "css"
10
11
  ],
11
12
  "keywords": [
12
13
  "stimulus",
@@ -8,16 +8,24 @@ export default class extends Controller {
8
8
  limitBehavior: { type: String, default: "disable" },
9
9
  sortable: { type: Boolean, default: false },
10
10
  positionField: { type: String, default: "position" },
11
- sortHandle: { type: String, default: "" }
11
+ sortHandle: { type: String, default: "" },
12
+ animation: { type: String, default: "" },
13
+ animationDuration: { type: Number, default: 300 },
14
+ a11y: { type: Boolean, default: true }
12
15
  }
13
16
 
14
17
  connect() {
18
+ if (this.a11yValue) this.setupAccessibility()
15
19
  this.updateButtonStates()
16
20
  if (this.sortableValue) this.initializeSortable()
17
21
  }
18
22
 
19
23
  disconnect() {
20
24
  if (this.sortableInstance) this.sortableInstance.destroy()
25
+ if (this.liveRegion) {
26
+ this.liveRegion.remove()
27
+ this.liveRegion = null
28
+ }
21
29
  }
22
30
 
23
31
  get currentCount() {
@@ -42,7 +50,7 @@ export default class extends Controller {
42
50
  return
43
51
  }
44
52
 
45
- const template = event.currentTarget.dataset.template
53
+ const template = this.getTemplate(event.currentTarget)
46
54
  const insertion = event.currentTarget.dataset.insertion || "before"
47
55
  const targetSelector = event.currentTarget.dataset.target
48
56
  const count = parseInt(event.currentTarget.dataset.count) || 1
@@ -76,6 +84,14 @@ export default class extends Controller {
76
84
 
77
85
  if (beforeEvent.defaultPrevented) return
78
86
 
87
+ if (this.animationValue) {
88
+ this.animateOut(wrapper, () => this.removeElement(wrapper))
89
+ } else {
90
+ this.removeElement(wrapper)
91
+ }
92
+ }
93
+
94
+ removeElement(wrapper) {
79
95
  const destroyInput = wrapper.querySelector("input[name*='_destroy']")
80
96
 
81
97
  if (destroyInput) {
@@ -87,11 +103,31 @@ export default class extends Controller {
87
103
 
88
104
  this.dispatch("after-remove", { detail: { wrapper } })
89
105
  this.updateButtonStates()
106
+
107
+ if (this.a11yValue) {
108
+ const addButton = this.element.querySelector('[data-action*="nested-form#add"]')
109
+ if (addButton) addButton.focus()
110
+ this.announce(`Item removed. ${this.currentCount} remaining.`)
111
+ }
112
+ }
113
+
114
+ getTemplate(trigger) {
115
+ // Prefer <template> tag (handles deep nesting), fall back to data-template
116
+ const placeholder = trigger.dataset.placeholder
117
+ if (placeholder) {
118
+ const templateEl = this.element.querySelector(
119
+ `template[data-nested-form-template="${placeholder}"]`
120
+ )
121
+ if (templateEl) return templateEl.innerHTML
122
+ }
123
+ return trigger.dataset.template
90
124
  }
91
125
 
92
126
  insertFields(template, insertion, targetSelector, trigger) {
93
127
  const newId = new Date().getTime()
94
- const content = template.replace(/NEW_RECORD/g, newId)
128
+ const placeholder = trigger.dataset.placeholder || "NEW_RECORD"
129
+ const regex = new RegExp(placeholder, "g")
130
+ const content = template.replace(regex, newId)
95
131
 
96
132
  const fragment = document.createRange().createContextualFragment(content)
97
133
  const wrapper = fragment.firstElementChild
@@ -122,6 +158,144 @@ export default class extends Controller {
122
158
  }
123
159
 
124
160
  this.dispatch("after-add", { detail: { wrapper } })
161
+
162
+ if (this.animationValue) {
163
+ this.animateIn(wrapper)
164
+ }
165
+
166
+ if (this.a11yValue) {
167
+ this.focusFirstInput(wrapper)
168
+ this.announce(`Item ${this.currentCount} added.`)
169
+ }
170
+ }
171
+
172
+ // Duplicate
173
+
174
+ duplicate(event) {
175
+ event.preventDefault()
176
+
177
+ if (this.currentCount >= this.maxValue) {
178
+ this.dispatch("limit-reached", {
179
+ detail: { limit: this.maxValue, current: this.currentCount }
180
+ })
181
+ return
182
+ }
183
+
184
+ const wrapper = event.currentTarget.closest(`.${this.wrapperClassValue}`)
185
+ if (!wrapper) return
186
+
187
+ const beforeEvent = this.dispatch("before-duplicate", {
188
+ cancelable: true,
189
+ detail: { source: wrapper }
190
+ })
191
+
192
+ if (beforeEvent.defaultPrevented) return
193
+
194
+ const clone = wrapper.cloneNode(true)
195
+ const newId = new Date().getTime()
196
+
197
+ this.prepareClone(clone, newId)
198
+
199
+ wrapper.after(clone)
200
+
201
+ this.dispatch("after-duplicate", {
202
+ detail: { source: wrapper, clone: clone }
203
+ })
204
+
205
+ if (this.animationValue) {
206
+ this.animateIn(clone)
207
+ }
208
+
209
+ this.updateButtonStates()
210
+ if (this.sortableValue) this.updatePositions()
211
+
212
+ if (this.a11yValue) {
213
+ this.focusFirstInput(clone)
214
+ this.announce("Item duplicated.")
215
+ }
216
+ }
217
+
218
+ prepareClone(clone, newId) {
219
+ const idInput = clone.querySelector("input[name*='[id]'][type='hidden']")
220
+ if (idInput) idInput.remove()
221
+
222
+ const destroyInput = clone.querySelector("input[name*='_destroy']")
223
+ if (destroyInput) destroyInput.value = "false"
224
+
225
+ const elements = clone.querySelectorAll("input, select, textarea, label")
226
+ elements.forEach(el => {
227
+ if (el.name) {
228
+ el.name = el.name.replace(/\[\d+\]/, `[${newId}]`)
229
+ }
230
+ if (el.id) {
231
+ el.id = el.id.replace(/_\d+_/, `_${newId}_`)
232
+ }
233
+ if (el.htmlFor) {
234
+ el.htmlFor = el.htmlFor.replace(/_\d+_/, `_${newId}_`)
235
+ }
236
+ })
237
+
238
+ clone.style.display = ""
239
+ }
240
+
241
+ // Accessibility
242
+
243
+ setupAccessibility() {
244
+ this.element.setAttribute("role", "group")
245
+ if (!this.element.getAttribute("aria-label")) {
246
+ this.element.setAttribute("aria-label", "Nested form fields")
247
+ }
248
+
249
+ this.liveRegion = document.createElement("div")
250
+ this.liveRegion.setAttribute("aria-live", "polite")
251
+ this.liveRegion.setAttribute("aria-atomic", "true")
252
+ this.liveRegion.classList.add("nested-form-live-region")
253
+ this.liveRegion.style.cssText = "position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)"
254
+ this.element.appendChild(this.liveRegion)
255
+ }
256
+
257
+ announce(message) {
258
+ if (!this.liveRegion) return
259
+ this.liveRegion.textContent = ""
260
+ requestAnimationFrame(() => {
261
+ this.liveRegion.textContent = message
262
+ })
263
+ }
264
+
265
+ focusFirstInput(wrapper) {
266
+ const focusable = wrapper.querySelector(
267
+ 'input:not([type="hidden"]), select, textarea'
268
+ )
269
+ if (focusable) {
270
+ setTimeout(() => focusable.focus(), 50)
271
+ }
272
+ }
273
+
274
+ // Animations
275
+
276
+ animateIn(element) {
277
+ element.classList.add("nested-form-enter")
278
+ requestAnimationFrame(() => {
279
+ requestAnimationFrame(() => {
280
+ element.classList.add("nested-form-enter-active")
281
+ setTimeout(() => {
282
+ element.classList.remove("nested-form-enter", "nested-form-enter-active")
283
+ }, this.animationDurationValue)
284
+ })
285
+ })
286
+ }
287
+
288
+ animateOut(element, callback) {
289
+ element.classList.add("nested-form-exit-active")
290
+
291
+ const done = () => {
292
+ element.classList.remove("nested-form-exit-active")
293
+ callback()
294
+ }
295
+
296
+ element.addEventListener("transitionend", done, { once: true })
297
+ // Fallback if transitionend doesn't fire
298
+ setTimeout(done, this.animationDurationValue + 50)
125
299
  }
126
300
 
127
301
  updateButtonStates() {
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hotwire_nested_form
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - BhumitBhadani
@@ -40,20 +40,24 @@ files:
40
40
  - LICENSE
41
41
  - README.md
42
42
  - Rakefile
43
+ - app/assets/stylesheets/hotwire_nested_form/animations.css
43
44
  - gemfiles/rails_7.0.gemfile
44
45
  - gemfiles/rails_7.1.gemfile
45
46
  - gemfiles/rails_8.0.gemfile
46
47
  - lib/generators/hotwire_nested_form/install_generator.rb
48
+ - lib/generators/hotwire_nested_form/templates/animations.css
47
49
  - lib/generators/hotwire_nested_form/templates/nested_form_controller.js
48
50
  - lib/hotwire_nested_form.rb
49
51
  - lib/hotwire_nested_form/engine.rb
50
52
  - lib/hotwire_nested_form/form_builder_detector.rb
51
53
  - lib/hotwire_nested_form/helpers.rb
52
54
  - lib/hotwire_nested_form/helpers/add_association.rb
55
+ - lib/hotwire_nested_form/helpers/duplicate_association.rb
53
56
  - lib/hotwire_nested_form/helpers/remove_association.rb
54
57
  - lib/hotwire_nested_form/version.rb
55
58
  - npm/.npmignore
56
59
  - npm/README.md
60
+ - npm/css/animations.css
57
61
  - npm/package.json
58
62
  - npm/src/index.js
59
63
  - npm/src/nested_form_controller.js