hotwire_nested_form 1.3.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: de50acd45ca73b2f450facf4306904957892b14438c12e4b64aac7fa02eed9e9
4
- data.tar.gz: c3e7e5096d346ba91d27f61ce6c20c28f3a076763769ab1e895b89df943a081d
3
+ metadata.gz: 173878e857afa1600895b0db444cb9c60785b587e79599c3bbc7759bc861f587
4
+ data.tar.gz: 79c140402e129ae87e72525fee18ca125dfe0d2720ea6455c9b88b926996a19a
5
5
  SHA512:
6
- metadata.gz: ead6225736617ac2c6367effc43422804167929c0d5fca1962165627e89d589c8c001f0e476f96eee2c8050de0d765863fe7bc8aa3b48a9f027ef7f9eb3ca034
7
- data.tar.gz: cde5e3da91c82bd35ea3813f45a576b9ad872fbd1b806dcd35df6830e49597f6c34288d3bc3ec780d9bc2da946be7f68a01fd5021fe6efe59a60d2876f1f59bd
6
+ metadata.gz: 5eb667ddcbf921baaa2f7b92cd618b40e8bf639fe344c9df3b696c8aee5f81abd9e611ca6c5e1f7cc671a894509ccabfa296584a319e28493bf4e9a22dcc7b30
7
+ data.tar.gz: c644e0a39ef9e67ff612f0d640617ab22ebfe4fa46a3062178e44d8dad1d6d5d86457ecba53ab5664b965f86e8fd981fb7b19163765026be1eb7b51ec992608a
data/CHANGELOG.md CHANGED
@@ -7,6 +7,28 @@ 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
+
10
32
  ## [1.3.0] - 2026-02-06
11
33
 
12
34
  ### 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.4.0)
5
5
  rails (>= 7.0)
6
6
 
7
7
  GEM
data/README.md CHANGED
@@ -272,6 +272,123 @@ 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
+
275
392
  ## NPM Package (JavaScript-only)
276
393
 
277
394
  For non-Rails projects using Stimulus, install via npm:
@@ -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,7 +8,9 @@ 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 }
12
14
  }
13
15
 
14
16
  connect() {
@@ -42,7 +44,7 @@ export default class extends Controller {
42
44
  return
43
45
  }
44
46
 
45
- const template = event.currentTarget.dataset.template
47
+ const template = this.getTemplate(event.currentTarget)
46
48
  const insertion = event.currentTarget.dataset.insertion || "before"
47
49
  const targetSelector = event.currentTarget.dataset.target
48
50
  const count = parseInt(event.currentTarget.dataset.count) || 1
@@ -76,6 +78,14 @@ export default class extends Controller {
76
78
 
77
79
  if (beforeEvent.defaultPrevented) return
78
80
 
81
+ if (this.animationValue) {
82
+ this.animateOut(wrapper, () => this.removeElement(wrapper))
83
+ } else {
84
+ this.removeElement(wrapper)
85
+ }
86
+ }
87
+
88
+ removeElement(wrapper) {
79
89
  const destroyInput = wrapper.querySelector("input[name*='_destroy']")
80
90
 
81
91
  if (destroyInput) {
@@ -89,9 +99,23 @@ export default class extends Controller {
89
99
  this.updateButtonStates()
90
100
  }
91
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
+
92
114
  insertFields(template, insertion, targetSelector, trigger) {
93
115
  const newId = new Date().getTime()
94
- 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)
95
119
 
96
120
  const fragment = document.createRange().createContextualFragment(content)
97
121
  const wrapper = fragment.firstElementChild
@@ -122,6 +146,37 @@ export default class extends Controller {
122
146
  }
123
147
 
124
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)
125
180
  }
126
181
 
127
182
  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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HotwireNestedForm
4
- VERSION = '1.3.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
@@ -98,6 +108,64 @@ 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
+
101
169
  ### Events
102
170
 
103
171
  | Event | Cancelable | Detail |
@@ -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.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",
@@ -8,7 +8,9 @@ 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 }
12
14
  }
13
15
 
14
16
  connect() {
@@ -42,7 +44,7 @@ export default class extends Controller {
42
44
  return
43
45
  }
44
46
 
45
- const template = event.currentTarget.dataset.template
47
+ const template = this.getTemplate(event.currentTarget)
46
48
  const insertion = event.currentTarget.dataset.insertion || "before"
47
49
  const targetSelector = event.currentTarget.dataset.target
48
50
  const count = parseInt(event.currentTarget.dataset.count) || 1
@@ -76,6 +78,14 @@ export default class extends Controller {
76
78
 
77
79
  if (beforeEvent.defaultPrevented) return
78
80
 
81
+ if (this.animationValue) {
82
+ this.animateOut(wrapper, () => this.removeElement(wrapper))
83
+ } else {
84
+ this.removeElement(wrapper)
85
+ }
86
+ }
87
+
88
+ removeElement(wrapper) {
79
89
  const destroyInput = wrapper.querySelector("input[name*='_destroy']")
80
90
 
81
91
  if (destroyInput) {
@@ -89,9 +99,23 @@ export default class extends Controller {
89
99
  this.updateButtonStates()
90
100
  }
91
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
+
92
114
  insertFields(template, insertion, targetSelector, trigger) {
93
115
  const newId = new Date().getTime()
94
- 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)
95
119
 
96
120
  const fragment = document.createRange().createContextualFragment(content)
97
121
  const wrapper = fragment.firstElementChild
@@ -122,6 +146,37 @@ export default class extends Controller {
122
146
  }
123
147
 
124
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)
125
180
  }
126
181
 
127
182
  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.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - BhumitBhadani
@@ -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