hotwire_nested_form 1.2.0 → 1.4.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: 95575593f362dd0db15938a6b130eb2fa7353e12b4c939c84ee4aa1eb00035ec
4
- data.tar.gz: 8ace6c3f971faaadc42d2692a6540bd4a907387302963573a904ad7990b1a3b6
3
+ metadata.gz: 173878e857afa1600895b0db444cb9c60785b587e79599c3bbc7759bc861f587
4
+ data.tar.gz: 79c140402e129ae87e72525fee18ca125dfe0d2720ea6455c9b88b926996a19a
5
5
  SHA512:
6
- metadata.gz: 409ccb4b58b6be6caba5a0b8b5634f999337677de6c146ea3bb100bb3bab9496dca69f4ae5c72ed2587419d321552bb4c7bfb09788ac7664069c0fd2e5ae7e0d
7
- data.tar.gz: 4b5902764ddc0e1e7b3223cd6f449a325089198a4a0fadee4077638eb7b403bc0179f0105233950c63b73f828339c6b796f577e3d807e67ec66137e286784791
6
+ metadata.gz: 5eb667ddcbf921baaa2f7b92cd618b40e8bf639fe344c9df3b696c8aee5f81abd9e611ca6c5e1f7cc671a894509ccabfa296584a319e28493bf4e9a22dcc7b30
7
+ data.tar.gz: c644e0a39ef9e67ff612f0d640617ab22ebfe4fa46a3062178e44d8dad1d6d5d86457ecba53ab5664b965f86e8fd981fb7b19163765026be1eb7b51ec992608a
data/CHANGELOG.md CHANGED
@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.4.0] - 2026-02-06
11
+
12
+ ### Added
13
+ - Add/remove animations with CSS transitions
14
+ - `data-nested-form-animation-value` - animation type: `"fade"`, `"slide"`, or `""` (none)
15
+ - `data-nested-form-animation-duration-value` - duration in ms (default: 300)
16
+ - CSS classes: `nested-form-enter`, `nested-form-enter-active`, `nested-form-exit-active`
17
+ - Optional animation stylesheet: `hotwire_nested_form/animations.css`
18
+ - Install with: `rails g hotwire_nested_form:install --animations`
19
+ - NPM users: `import "hotwire-nested-form-stimulus/css/animations.css"`
20
+ - Deep nesting (multi-level nested forms)
21
+ - Association-specific placeholders (`NEW_TASK_RECORD`, `NEW_SUBTASK_RECORD`) prevent collisions
22
+ - Each `link_to_add_association` automatically generates unique placeholders per association
23
+ - `<template>` tags for template storage (replaces `data-template` attribute for reliable deep nesting)
24
+ - Full backward compatibility - single-level forms work unchanged
25
+
26
+ ### Changed
27
+ - Template HTML now stored in `<template>` tags instead of `data-template` attributes
28
+ - `link_to_add_association` outputs `<template>` + `<a>` tag pair
29
+ - Controller `remove()` refactored into `remove()` + `removeElement()` for animation support
30
+ - Added `getTemplate()` method for flexible template lookup
31
+
32
+ ## [1.3.0] - 2026-02-06
33
+
34
+ ### Added
35
+ - Drag & drop sorting for nested items (requires SortableJS)
36
+ - `data-nested-form-sortable-value` - enable sorting
37
+ - `data-nested-form-position-field-value` - custom position field name
38
+ - `data-nested-form-sort-handle-value` - CSS selector for drag handle
39
+ - New events: `nested-form:before-sort` and `nested-form:after-sort`
40
+ - CSS classes for drag styling: `nested-form-dragging`, `nested-form-drag-ghost`
41
+
42
+ ### Changed
43
+ - Controller now cleans up Sortable instance on disconnect
44
+
10
45
  ## [1.2.0] - 2026-02-05
11
46
 
12
47
  ### Added
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- hotwire_nested_form (1.2.0)
4
+ hotwire_nested_form (1.4.0)
5
5
  rails (>= 7.0)
6
6
 
7
7
  GEM
data/README.md CHANGED
@@ -180,6 +180,215 @@ document.addEventListener("nested-form:minimum-reached", (event) => {
180
180
  })
181
181
  ```
182
182
 
183
+ ## Drag & Drop Sorting
184
+
185
+ Enable drag & drop reordering with position persistence:
186
+
187
+ ### 1. Install SortableJS
188
+
189
+ ```bash
190
+ # Rails with importmap
191
+ bin/importmap pin sortablejs
192
+
193
+ # OR npm/yarn
194
+ npm install sortablejs
195
+ ```
196
+
197
+ ### 2. Add Position to Your Model
198
+
199
+ ```bash
200
+ rails generate migration AddPositionToTasks position:integer
201
+ rails db:migrate
202
+ ```
203
+
204
+ ```ruby
205
+ # app/models/task.rb
206
+ class Task < ApplicationRecord
207
+ belongs_to :project
208
+ default_scope { order(:position) }
209
+ end
210
+ ```
211
+
212
+ ### 3. Update Your Partial
213
+
214
+ ```erb
215
+ <%# app/views/projects/_task_fields.html.erb %>
216
+ <div class="nested-fields">
217
+ <%= f.hidden_field :position %>
218
+ <span class="drag-handle">☰</span>
219
+ <%= f.text_field :name %>
220
+ <%= link_to_remove_association "Remove", f %>
221
+ </div>
222
+ ```
223
+
224
+ ### 4. Enable Sorting
225
+
226
+ ```erb
227
+ <div data-controller="nested-form"
228
+ data-nested-form-sortable-value="true"
229
+ data-nested-form-sort-handle-value=".drag-handle">
230
+ <!-- nested fields -->
231
+ </div>
232
+ ```
233
+
234
+ ### 5. Permit Position in Controller
235
+
236
+ ```ruby
237
+ params.require(:project).permit(:name,
238
+ tasks_attributes: [:id, :name, :position, :_destroy])
239
+ ```
240
+
241
+ ### Sorting Options
242
+
243
+ | Attribute | Default | Description |
244
+ |-----------|---------|-------------|
245
+ | `data-nested-form-sortable-value` | `false` | Enable drag & drop |
246
+ | `data-nested-form-position-field-value` | `"position"` | Position field name |
247
+ | `data-nested-form-sort-handle-value` | (none) | Drag handle selector |
248
+
249
+ ### Sorting Events
250
+
251
+ | Event | Detail | Description |
252
+ |-------|--------|-------------|
253
+ | `nested-form:before-sort` | `{ item, oldIndex }` | Before drag (cancelable) |
254
+ | `nested-form:after-sort` | `{ item, oldIndex, newIndex }` | After drop |
255
+
256
+ ### Example CSS
257
+
258
+ ```css
259
+ .drag-handle {
260
+ cursor: grab;
261
+ user-select: none;
262
+ }
263
+
264
+ .nested-form-dragging {
265
+ opacity: 0.8;
266
+ background: #e3f2fd;
267
+ }
268
+
269
+ .nested-form-drag-ghost {
270
+ opacity: 0.4;
271
+ border: 2px dashed #2196F3;
272
+ }
273
+ ```
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
+
183
392
  ## NPM Package (JavaScript-only)
184
393
 
185
394
  For non-Rails projects using Stimulus, install via npm:
@@ -276,6 +485,8 @@ link_to_remove_association(name, form, options = {}, &block)
276
485
  | `nested-form:after-remove` | No | `{ wrapper }` | After fields removed |
277
486
  | `nested-form:limit-reached` | No | `{ limit, current }` | When max limit reached |
278
487
  | `nested-form:minimum-reached` | No | `{ minimum, current }` | When min limit reached |
488
+ | `nested-form:before-sort` | Yes | `{ item, oldIndex }` | Before drag starts |
489
+ | `nested-form:after-sort` | No | `{ item, oldIndex, newIndex }` | After drop completes |
279
490
 
280
491
  **Usage Examples:**
281
492
 
@@ -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
+ }
@@ -5,11 +5,21 @@ export default class extends Controller {
5
5
  wrapperClass: { type: String, default: "nested-fields" },
6
6
  min: { type: Number, default: 0 },
7
7
  max: { type: Number, default: 999999 },
8
- limitBehavior: { type: String, default: "disable" }
8
+ limitBehavior: { type: String, default: "disable" },
9
+ sortable: { type: Boolean, default: false },
10
+ positionField: { type: String, default: "position" },
11
+ sortHandle: { type: String, default: "" },
12
+ animation: { type: String, default: "" },
13
+ animationDuration: { type: Number, default: 300 }
9
14
  }
10
15
 
11
16
  connect() {
12
17
  this.updateButtonStates()
18
+ if (this.sortableValue) this.initializeSortable()
19
+ }
20
+
21
+ disconnect() {
22
+ if (this.sortableInstance) this.sortableInstance.destroy()
13
23
  }
14
24
 
15
25
  get currentCount() {
@@ -34,7 +44,7 @@ export default class extends Controller {
34
44
  return
35
45
  }
36
46
 
37
- const template = event.currentTarget.dataset.template
47
+ const template = this.getTemplate(event.currentTarget)
38
48
  const insertion = event.currentTarget.dataset.insertion || "before"
39
49
  const targetSelector = event.currentTarget.dataset.target
40
50
  const count = parseInt(event.currentTarget.dataset.count) || 1
@@ -45,6 +55,7 @@ export default class extends Controller {
45
55
  }
46
56
 
47
57
  this.updateButtonStates()
58
+ if (this.sortableValue && !this.sortableInstance) this.initializeSortable()
48
59
  }
49
60
 
50
61
  remove(event) {
@@ -67,6 +78,14 @@ export default class extends Controller {
67
78
 
68
79
  if (beforeEvent.defaultPrevented) return
69
80
 
81
+ if (this.animationValue) {
82
+ this.animateOut(wrapper, () => this.removeElement(wrapper))
83
+ } else {
84
+ this.removeElement(wrapper)
85
+ }
86
+ }
87
+
88
+ removeElement(wrapper) {
70
89
  const destroyInput = wrapper.querySelector("input[name*='_destroy']")
71
90
 
72
91
  if (destroyInput) {
@@ -80,9 +99,23 @@ export default class extends Controller {
80
99
  this.updateButtonStates()
81
100
  }
82
101
 
102
+ getTemplate(trigger) {
103
+ // Prefer <template> tag (handles deep nesting), fall back to data-template
104
+ const placeholder = trigger.dataset.placeholder
105
+ if (placeholder) {
106
+ const templateEl = this.element.querySelector(
107
+ `template[data-nested-form-template="${placeholder}"]`
108
+ )
109
+ if (templateEl) return templateEl.innerHTML
110
+ }
111
+ return trigger.dataset.template
112
+ }
113
+
83
114
  insertFields(template, insertion, targetSelector, trigger) {
84
115
  const newId = new Date().getTime()
85
- const content = template.replace(/NEW_RECORD/g, newId)
116
+ const placeholder = trigger.dataset.placeholder || "NEW_RECORD"
117
+ const regex = new RegExp(placeholder, "g")
118
+ const content = template.replace(regex, newId)
86
119
 
87
120
  const fragment = document.createRange().createContextualFragment(content)
88
121
  const wrapper = fragment.firstElementChild
@@ -113,6 +146,37 @@ export default class extends Controller {
113
146
  }
114
147
 
115
148
  this.dispatch("after-add", { detail: { wrapper } })
149
+
150
+ if (this.animationValue) {
151
+ this.animateIn(wrapper)
152
+ }
153
+ }
154
+
155
+ // Animations
156
+
157
+ animateIn(element) {
158
+ element.classList.add("nested-form-enter")
159
+ requestAnimationFrame(() => {
160
+ requestAnimationFrame(() => {
161
+ element.classList.add("nested-form-enter-active")
162
+ setTimeout(() => {
163
+ element.classList.remove("nested-form-enter", "nested-form-enter-active")
164
+ }, this.animationDurationValue)
165
+ })
166
+ })
167
+ }
168
+
169
+ animateOut(element, callback) {
170
+ element.classList.add("nested-form-exit-active")
171
+
172
+ const done = () => {
173
+ element.classList.remove("nested-form-exit-active")
174
+ callback()
175
+ }
176
+
177
+ element.addEventListener("transitionend", done, { once: true })
178
+ // Fallback if transitionend doesn't fire
179
+ setTimeout(done, this.animationDurationValue + 50)
116
180
  }
117
181
 
118
182
  updateButtonStates() {
@@ -146,4 +210,79 @@ export default class extends Controller {
146
210
  button.style.display = ""
147
211
  }
148
212
  }
213
+
214
+ // Drag & Drop Sorting
215
+
216
+ initializeSortable() {
217
+ if (typeof Sortable === 'undefined') {
218
+ console.warn('hotwire_nested_form: SortableJS not found. Install it for drag & drop sorting: https://sortablejs.github.io/Sortable/')
219
+ return
220
+ }
221
+
222
+ const container = this.findSortableContainer()
223
+ if (!container) return
224
+
225
+ this.sortableInstance = Sortable.create(container, {
226
+ animation: 150,
227
+ handle: this.sortHandleValue || null,
228
+ draggable: `.${this.wrapperClassValue}`,
229
+ ghostClass: 'nested-form-drag-ghost',
230
+ chosenClass: 'nested-form-dragging',
231
+ onStart: (evt) => this.onSortStart(evt),
232
+ onEnd: (evt) => this.onSortEnd(evt)
233
+ })
234
+ }
235
+
236
+ findSortableContainer() {
237
+ // Look for common container patterns
238
+ const selectors = ['#tasks', '#items', '[data-nested-form-target="container"]']
239
+ for (const selector of selectors) {
240
+ const container = this.element.querySelector(selector)
241
+ if (container) return container
242
+ }
243
+ // Fallback: find first element containing nested-fields
244
+ const firstField = this.element.querySelector(`.${this.wrapperClassValue}`)
245
+ return firstField ? firstField.parentElement : this.element
246
+ }
247
+
248
+ onSortStart(evt) {
249
+ const beforeEvent = this.dispatch("before-sort", {
250
+ cancelable: true,
251
+ detail: { item: evt.item, oldIndex: evt.oldIndex }
252
+ })
253
+
254
+ if (beforeEvent.defaultPrevented) {
255
+ this.sortableInstance.option("disabled", true)
256
+ setTimeout(() => this.sortableInstance.option("disabled", false), 0)
257
+ }
258
+ }
259
+
260
+ onSortEnd(evt) {
261
+ this.updatePositions()
262
+
263
+ this.dispatch("after-sort", {
264
+ detail: {
265
+ item: evt.item,
266
+ oldIndex: evt.oldIndex,
267
+ newIndex: evt.newIndex
268
+ }
269
+ })
270
+
271
+ this.updateButtonStates()
272
+ }
273
+
274
+ updatePositions() {
275
+ const items = this.element.querySelectorAll(
276
+ `.${this.wrapperClassValue}:not([style*="display: none"])`
277
+ )
278
+
279
+ items.forEach((item, index) => {
280
+ const positionInput = item.querySelector(
281
+ `input[name*="[${this.positionFieldValue}]"]`
282
+ )
283
+ if (positionInput) {
284
+ positionInput.value = index + 1
285
+ }
286
+ })
287
+ }
149
288
  }
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HotwireNestedForm
4
- VERSION = '1.2.0'
4
+ VERSION = '1.4.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
@@ -64,6 +74,98 @@ application.register("nested-form", NestedFormController)
64
74
  | `data-nested-form-max-value` | Maximum items allowed | unlimited |
65
75
  | `data-nested-form-limit-behavior-value` | `"disable"`, `"hide"`, or `"error"` | `"disable"` |
66
76
 
77
+ ### Drag & Drop Sorting
78
+
79
+ Requires [SortableJS](https://sortablejs.github.io/Sortable/):
80
+
81
+ ```bash
82
+ npm install sortablejs
83
+ ```
84
+
85
+ ```javascript
86
+ import Sortable from 'sortablejs'
87
+ window.Sortable = Sortable
88
+ ```
89
+
90
+ ```html
91
+ <div data-controller="nested-form"
92
+ data-nested-form-sortable-value="true"
93
+ data-nested-form-sort-handle-value=".drag-handle">
94
+
95
+ <div id="items">
96
+ <div class="nested-fields">
97
+ <input type="hidden" name="items[][position]" value="1">
98
+ <span class="drag-handle">☰</span>
99
+ <!-- other fields -->
100
+ </div>
101
+ </div>
102
+ </div>
103
+ ```
104
+
105
+ | Attribute | Default | Description |
106
+ |-----------|---------|-------------|
107
+ | `data-nested-form-sortable-value` | `false` | Enable sorting |
108
+ | `data-nested-form-position-field-value` | `"position"` | Position field name |
109
+ | `data-nested-form-sort-handle-value` | (none) | Drag handle selector |
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
+
67
169
  ### Events
68
170
 
69
171
  | Event | Cancelable | Detail |
@@ -74,6 +176,8 @@ application.register("nested-form", NestedFormController)
74
176
  | `nested-form:after-remove` | No | `{ wrapper }` |
75
177
  | `nested-form:limit-reached` | No | `{ limit, current }` |
76
178
  | `nested-form:minimum-reached` | No | `{ minimum, current }` |
179
+ | `nested-form:before-sort` | Yes | `{ item, oldIndex }` |
180
+ | `nested-form:after-sort` | No | `{ item, oldIndex, newIndex }` |
77
181
 
78
182
  ### Example: Listen for Events
79
183
 
@@ -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.2.0",
3
+ "version": "1.4.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",
@@ -5,11 +5,21 @@ export default class extends Controller {
5
5
  wrapperClass: { type: String, default: "nested-fields" },
6
6
  min: { type: Number, default: 0 },
7
7
  max: { type: Number, default: 999999 },
8
- limitBehavior: { type: String, default: "disable" }
8
+ limitBehavior: { type: String, default: "disable" },
9
+ sortable: { type: Boolean, default: false },
10
+ positionField: { type: String, default: "position" },
11
+ sortHandle: { type: String, default: "" },
12
+ animation: { type: String, default: "" },
13
+ animationDuration: { type: Number, default: 300 }
9
14
  }
10
15
 
11
16
  connect() {
12
17
  this.updateButtonStates()
18
+ if (this.sortableValue) this.initializeSortable()
19
+ }
20
+
21
+ disconnect() {
22
+ if (this.sortableInstance) this.sortableInstance.destroy()
13
23
  }
14
24
 
15
25
  get currentCount() {
@@ -34,7 +44,7 @@ export default class extends Controller {
34
44
  return
35
45
  }
36
46
 
37
- const template = event.currentTarget.dataset.template
47
+ const template = this.getTemplate(event.currentTarget)
38
48
  const insertion = event.currentTarget.dataset.insertion || "before"
39
49
  const targetSelector = event.currentTarget.dataset.target
40
50
  const count = parseInt(event.currentTarget.dataset.count) || 1
@@ -45,6 +55,7 @@ export default class extends Controller {
45
55
  }
46
56
 
47
57
  this.updateButtonStates()
58
+ if (this.sortableValue && !this.sortableInstance) this.initializeSortable()
48
59
  }
49
60
 
50
61
  remove(event) {
@@ -67,6 +78,14 @@ export default class extends Controller {
67
78
 
68
79
  if (beforeEvent.defaultPrevented) return
69
80
 
81
+ if (this.animationValue) {
82
+ this.animateOut(wrapper, () => this.removeElement(wrapper))
83
+ } else {
84
+ this.removeElement(wrapper)
85
+ }
86
+ }
87
+
88
+ removeElement(wrapper) {
70
89
  const destroyInput = wrapper.querySelector("input[name*='_destroy']")
71
90
 
72
91
  if (destroyInput) {
@@ -80,9 +99,23 @@ export default class extends Controller {
80
99
  this.updateButtonStates()
81
100
  }
82
101
 
102
+ getTemplate(trigger) {
103
+ // Prefer <template> tag (handles deep nesting), fall back to data-template
104
+ const placeholder = trigger.dataset.placeholder
105
+ if (placeholder) {
106
+ const templateEl = this.element.querySelector(
107
+ `template[data-nested-form-template="${placeholder}"]`
108
+ )
109
+ if (templateEl) return templateEl.innerHTML
110
+ }
111
+ return trigger.dataset.template
112
+ }
113
+
83
114
  insertFields(template, insertion, targetSelector, trigger) {
84
115
  const newId = new Date().getTime()
85
- const content = template.replace(/NEW_RECORD/g, newId)
116
+ const placeholder = trigger.dataset.placeholder || "NEW_RECORD"
117
+ const regex = new RegExp(placeholder, "g")
118
+ const content = template.replace(regex, newId)
86
119
 
87
120
  const fragment = document.createRange().createContextualFragment(content)
88
121
  const wrapper = fragment.firstElementChild
@@ -113,6 +146,37 @@ export default class extends Controller {
113
146
  }
114
147
 
115
148
  this.dispatch("after-add", { detail: { wrapper } })
149
+
150
+ if (this.animationValue) {
151
+ this.animateIn(wrapper)
152
+ }
153
+ }
154
+
155
+ // Animations
156
+
157
+ animateIn(element) {
158
+ element.classList.add("nested-form-enter")
159
+ requestAnimationFrame(() => {
160
+ requestAnimationFrame(() => {
161
+ element.classList.add("nested-form-enter-active")
162
+ setTimeout(() => {
163
+ element.classList.remove("nested-form-enter", "nested-form-enter-active")
164
+ }, this.animationDurationValue)
165
+ })
166
+ })
167
+ }
168
+
169
+ animateOut(element, callback) {
170
+ element.classList.add("nested-form-exit-active")
171
+
172
+ const done = () => {
173
+ element.classList.remove("nested-form-exit-active")
174
+ callback()
175
+ }
176
+
177
+ element.addEventListener("transitionend", done, { once: true })
178
+ // Fallback if transitionend doesn't fire
179
+ setTimeout(done, this.animationDurationValue + 50)
116
180
  }
117
181
 
118
182
  updateButtonStates() {
@@ -146,4 +210,79 @@ export default class extends Controller {
146
210
  button.style.display = ""
147
211
  }
148
212
  }
213
+
214
+ // Drag & Drop Sorting
215
+
216
+ initializeSortable() {
217
+ if (typeof Sortable === 'undefined') {
218
+ console.warn('hotwire_nested_form: SortableJS not found. Install it for drag & drop sorting: https://sortablejs.github.io/Sortable/')
219
+ return
220
+ }
221
+
222
+ const container = this.findSortableContainer()
223
+ if (!container) return
224
+
225
+ this.sortableInstance = Sortable.create(container, {
226
+ animation: 150,
227
+ handle: this.sortHandleValue || null,
228
+ draggable: `.${this.wrapperClassValue}`,
229
+ ghostClass: 'nested-form-drag-ghost',
230
+ chosenClass: 'nested-form-dragging',
231
+ onStart: (evt) => this.onSortStart(evt),
232
+ onEnd: (evt) => this.onSortEnd(evt)
233
+ })
234
+ }
235
+
236
+ findSortableContainer() {
237
+ // Look for common container patterns
238
+ const selectors = ['#tasks', '#items', '[data-nested-form-target="container"]']
239
+ for (const selector of selectors) {
240
+ const container = this.element.querySelector(selector)
241
+ if (container) return container
242
+ }
243
+ // Fallback: find first element containing nested-fields
244
+ const firstField = this.element.querySelector(`.${this.wrapperClassValue}`)
245
+ return firstField ? firstField.parentElement : this.element
246
+ }
247
+
248
+ onSortStart(evt) {
249
+ const beforeEvent = this.dispatch("before-sort", {
250
+ cancelable: true,
251
+ detail: { item: evt.item, oldIndex: evt.oldIndex }
252
+ })
253
+
254
+ if (beforeEvent.defaultPrevented) {
255
+ this.sortableInstance.option("disabled", true)
256
+ setTimeout(() => this.sortableInstance.option("disabled", false), 0)
257
+ }
258
+ }
259
+
260
+ onSortEnd(evt) {
261
+ this.updatePositions()
262
+
263
+ this.dispatch("after-sort", {
264
+ detail: {
265
+ item: evt.item,
266
+ oldIndex: evt.oldIndex,
267
+ newIndex: evt.newIndex
268
+ }
269
+ })
270
+
271
+ this.updateButtonStates()
272
+ }
273
+
274
+ updatePositions() {
275
+ const items = this.element.querySelectorAll(
276
+ `.${this.wrapperClassValue}:not([style*="display: none"])`
277
+ )
278
+
279
+ items.forEach((item, index) => {
280
+ const positionInput = item.querySelector(
281
+ `input[name*="[${this.positionFieldValue}]"]`
282
+ )
283
+ if (positionInput) {
284
+ positionInput.value = index + 1
285
+ }
286
+ })
287
+ }
149
288
  }
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hotwire_nested_form
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - BhumitBhadani
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-02-05 00:00:00.000000000 Z
11
+ date: 2026-02-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -40,10 +40,12 @@ 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
@@ -54,6 +56,7 @@ files:
54
56
  - lib/hotwire_nested_form/version.rb
55
57
  - npm/.npmignore
56
58
  - npm/README.md
59
+ - npm/css/animations.css
57
60
  - npm/package.json
58
61
  - npm/src/index.js
59
62
  - npm/src/nested_form_controller.js