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 +4 -4
- data/CHANGELOG.md +22 -0
- data/Gemfile.lock +1 -1
- data/README.md +117 -0
- data/app/assets/stylesheets/hotwire_nested_form/animations.css +47 -0
- data/lib/generators/hotwire_nested_form/install_generator.rb +10 -0
- data/lib/generators/hotwire_nested_form/templates/animations.css +47 -0
- data/lib/generators/hotwire_nested_form/templates/nested_form_controller.js +58 -3
- data/lib/hotwire_nested_form/helpers/add_association.rb +17 -9
- data/lib/hotwire_nested_form/version.rb +1 -1
- data/npm/README.md +71 -3
- data/npm/css/animations.css +46 -0
- data/npm/package.json +3 -2
- data/npm/src/nested_form_controller.js +58 -3
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 173878e857afa1600895b0db444cb9c60785b587e79599c3bbc7759bc861f587
|
|
4
|
+
data.tar.gz: 79c140402e129ae87e72525fee18ca125dfe0d2720ea6455c9b88b926996a19a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
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
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
82
|
-
|
|
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
|
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-
|
|
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-
|
|
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
|
+
"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
|
|
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
|
|
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.
|
|
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
|