hotwire_nested_form 1.1.0 → 1.3.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: 614ce7e4bb9ee960608d18431bd94319feac263ce6a83f79bc36525f5c2d5061
4
- data.tar.gz: 3c8c196d819cf4954fbedf3444070a95244636427bb73147d3edc71465d5afb2
3
+ metadata.gz: de50acd45ca73b2f450facf4306904957892b14438c12e4b64aac7fa02eed9e9
4
+ data.tar.gz: c3e7e5096d346ba91d27f61ce6c20c28f3a076763769ab1e895b89df943a081d
5
5
  SHA512:
6
- metadata.gz: 5ec8765adbc4c27dbd3297f6497971f29a22872381c114c12ce03c7ee8649e0e47e6c500be06c7f1ee71a3512f7897f9a94bc96f95474e730874828188784579
7
- data.tar.gz: d90976447adf75d242bdb1f200af575e3724036b7441876e48134a28f4abd7c603cc62cb9c997679a8234b19b3a6a3465e77e51fb944a144b25b0f4c4e1dc7ad
6
+ metadata.gz: ead6225736617ac2c6367effc43422804167929c0d5fca1962165627e89d589c8c001f0e476f96eee2c8050de0d765863fe7bc8aa3b48a9f027ef7f9eb3ca034
7
+ data.tar.gz: cde5e3da91c82bd35ea3813f45a576b9ad872fbd1b806dcd35df6830e49597f6c34288d3bc3ec780d9bc2da946be7f68a01fd5021fe6efe59a60d2876f1f59bd
data/CHANGELOG.md CHANGED
@@ -7,11 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.3.0] - 2026-02-06
11
+
12
+ ### Added
13
+ - Drag & drop sorting for nested items (requires SortableJS)
14
+ - `data-nested-form-sortable-value` - enable sorting
15
+ - `data-nested-form-position-field-value` - custom position field name
16
+ - `data-nested-form-sort-handle-value` - CSS selector for drag handle
17
+ - New events: `nested-form:before-sort` and `nested-form:after-sort`
18
+ - CSS classes for drag styling: `nested-form-dragging`, `nested-form-drag-ghost`
19
+
20
+ ### Changed
21
+ - Controller now cleans up Sortable instance on disconnect
22
+
23
+ ## [1.2.0] - 2026-02-05
24
+
25
+ ### Added
26
+ - Dynamic min/max limits via data attributes
27
+ - `data-nested-form-min-value` - minimum items required
28
+ - `data-nested-form-max-value` - maximum items allowed
29
+ - `data-nested-form-limit-behavior-value` - behavior at limits: "disable", "hide", or "error"
30
+ - New events: `nested-form:limit-reached` and `nested-form:minimum-reached`
31
+ - Formtastic form builder auto-detection and compatibility
32
+ - `formtastic?` and `formtastic_available?` methods in FormBuilderDetector
33
+
34
+ ### Changed
35
+ - Stimulus controller now updates button states automatically
36
+ - Buttons disable/hide based on current count vs limits
37
+
10
38
  ## [1.1.0] - 2026-02-05
11
39
 
12
40
  ### Added
13
41
  - SimpleForm auto-detection and compatibility
14
- - NPM package `@hotwire-nested-form/stimulus` for JavaScript-only users
42
+ - NPM package `hotwire-nested-form-stimulus` for JavaScript-only users
15
43
  - `FormBuilderDetector` module for form builder type detection
16
44
 
17
45
  ### Changed
data/Gemfile CHANGED
@@ -11,6 +11,7 @@ gem 'rails', "~> #{rails_version}.0"
11
11
 
12
12
  group :development, :test do
13
13
  gem 'capybara', '~> 3.39'
14
+ gem 'formtastic', '~> 5.0'
14
15
  gem 'puma', '~> 6.0'
15
16
  gem 'rspec-rails', '~> 6.0'
16
17
  gem 'selenium-webdriver', '~> 4.10'
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- hotwire_nested_form (1.1.0)
4
+ hotwire_nested_form (1.3.0)
5
5
  rails (>= 7.0)
6
6
 
7
7
  GEM
@@ -105,6 +105,8 @@ GEM
105
105
  drb (2.2.3)
106
106
  erb (6.0.1)
107
107
  erubi (1.13.1)
108
+ formtastic (5.0.0)
109
+ actionpack (>= 6.0.0)
108
110
  globalid (1.3.0)
109
111
  activesupport (>= 6.1)
110
112
  i18n (1.14.8)
@@ -314,6 +316,7 @@ PLATFORMS
314
316
  DEPENDENCIES
315
317
  capybara (~> 3.39)
316
318
  debug
319
+ formtastic (~> 5.0)
317
320
  hotwire_nested_form!
318
321
  puma (~> 6.0)
319
322
  rails (~> 8.0.0)
data/README.md CHANGED
@@ -104,19 +104,187 @@ Works automatically with SimpleForm! No configuration needed.
104
104
  <% end %>
105
105
  ```
106
106
 
107
+ ## Formtastic Support
108
+
109
+ Works automatically with Formtastic! No configuration needed.
110
+
111
+ ```erb
112
+ <%= semantic_form_for @project do |f| %>
113
+ <%= f.input :name %>
114
+
115
+ <div data-controller="nested-form">
116
+ <%= f.semantic_fields_for :tasks do |task_form| %>
117
+ <%= render "task_fields", f: task_form %>
118
+ <% end %>
119
+
120
+ <%= link_to_add_association "Add Task", f, :tasks %>
121
+ </div>
122
+
123
+ <%= f.actions %>
124
+ <% end %>
125
+ ```
126
+
127
+ ## Min/Max Limits
128
+
129
+ Control the number of nested items with data attributes:
130
+
131
+ ```erb
132
+ <div data-controller="nested-form"
133
+ data-nested-form-min-value="1"
134
+ data-nested-form-max-value="5"
135
+ data-nested-form-limit-behavior-value="disable">
136
+
137
+ <%= f.fields_for :tasks do |tf| %>
138
+ <%= render "task_fields", f: tf %>
139
+ <% end %>
140
+
141
+ <%= link_to_add_association "Add Task", f, :tasks %>
142
+ </div>
143
+ ```
144
+
145
+ ### Limit Options
146
+
147
+ | Attribute | Type | Default | Description |
148
+ |-----------|------|---------|-------------|
149
+ | `data-nested-form-min-value` | Integer | `0` | Minimum items required |
150
+ | `data-nested-form-max-value` | Integer | unlimited | Maximum items allowed |
151
+ | `data-nested-form-limit-behavior-value` | String | `"disable"` | `"disable"`, `"hide"`, or `"error"` |
152
+
153
+ ### Limit Behaviors
154
+
155
+ | Behavior | At Max Limit | At Min Limit |
156
+ |----------|--------------|--------------|
157
+ | `disable` | Add button disabled | Remove buttons disabled |
158
+ | `hide` | Add button hidden | Remove buttons hidden |
159
+ | `error` | Event fires, button enabled | Event fires, button enabled |
160
+
161
+ ### Dynamic Limits
162
+
163
+ Change limits at runtime via JavaScript:
164
+
165
+ ```javascript
166
+ const form = document.querySelector('[data-controller="nested-form"]')
167
+ form.dataset.nestedFormMaxValue = 10 // Change max
168
+ form.dataset.nestedFormMinValue = 2 // Change min
169
+ ```
170
+
171
+ ### Limit Events
172
+
173
+ ```javascript
174
+ document.addEventListener("nested-form:limit-reached", (event) => {
175
+ alert(`Maximum ${event.detail.limit} items allowed`)
176
+ })
177
+
178
+ document.addEventListener("nested-form:minimum-reached", (event) => {
179
+ alert(`Must keep at least ${event.detail.minimum} items`)
180
+ })
181
+ ```
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
+
107
275
  ## NPM Package (JavaScript-only)
108
276
 
109
277
  For non-Rails projects using Stimulus, install via npm:
110
278
 
111
279
  ```bash
112
- npm install @hotwire-nested-form/stimulus
280
+ npm install hotwire-nested-form-stimulus
113
281
  ```
114
282
 
115
283
  Register the controller:
116
284
 
117
285
  ```javascript
118
286
  import { Application } from "@hotwired/stimulus"
119
- import NestedFormController from "@hotwire-nested-form/stimulus"
287
+ import NestedFormController from "hotwire-nested-form-stimulus"
120
288
 
121
289
  const application = Application.start()
122
290
  application.register("nested-form", NestedFormController)
@@ -198,6 +366,10 @@ link_to_remove_association(name, form, options = {}, &block)
198
366
  | `nested-form:after-add` | No | `{ wrapper }` | After fields added |
199
367
  | `nested-form:before-remove` | Yes | `{ wrapper }` | Before removing fields |
200
368
  | `nested-form:after-remove` | No | `{ wrapper }` | After fields removed |
369
+ | `nested-form:limit-reached` | No | `{ limit, current }` | When max limit reached |
370
+ | `nested-form:minimum-reached` | No | `{ minimum, current }` | When min limit reached |
371
+ | `nested-form:before-sort` | Yes | `{ item, oldIndex }` | Before drag starts |
372
+ | `nested-form:after-sort` | No | `{ item, oldIndex, newIndex }` | After drop completes |
201
373
 
202
374
  **Usage Examples:**
203
375
 
@@ -1,31 +1,74 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
- // Connects to data-controller="nested-form"
4
3
  export default class extends Controller {
5
4
  static values = {
6
- wrapperClass: { type: String, default: "nested-fields" }
5
+ wrapperClass: { type: String, default: "nested-fields" },
6
+ min: { type: Number, default: 0 },
7
+ max: { type: Number, default: 999999 },
8
+ limitBehavior: { type: String, default: "disable" },
9
+ sortable: { type: Boolean, default: false },
10
+ positionField: { type: String, default: "position" },
11
+ sortHandle: { type: String, default: "" }
12
+ }
13
+
14
+ connect() {
15
+ this.updateButtonStates()
16
+ if (this.sortableValue) this.initializeSortable()
17
+ }
18
+
19
+ disconnect() {
20
+ if (this.sortableInstance) this.sortableInstance.destroy()
21
+ }
22
+
23
+ get currentCount() {
24
+ return this.element.querySelectorAll(`.${this.wrapperClassValue}:not([style*="display: none"])`).length
25
+ }
26
+
27
+ get addButtons() {
28
+ return this.element.querySelectorAll('[data-action*="nested-form#add"]')
29
+ }
30
+
31
+ get removeButtons() {
32
+ return this.element.querySelectorAll('[data-action*="nested-form#remove"]')
7
33
  }
8
34
 
9
35
  add(event) {
10
36
  event.preventDefault()
11
37
 
38
+ if (this.currentCount >= this.maxValue) {
39
+ this.dispatch("limit-reached", {
40
+ detail: { limit: this.maxValue, current: this.currentCount }
41
+ })
42
+ return
43
+ }
44
+
12
45
  const template = event.currentTarget.dataset.template
13
46
  const insertion = event.currentTarget.dataset.insertion || "before"
14
47
  const targetSelector = event.currentTarget.dataset.target
15
48
  const count = parseInt(event.currentTarget.dataset.count) || 1
16
49
 
17
50
  for (let i = 0; i < count; i++) {
18
- this.#insertFields(template, insertion, targetSelector, event.currentTarget)
51
+ if (this.currentCount >= this.maxValue) break
52
+ this.insertFields(template, insertion, targetSelector, event.currentTarget)
19
53
  }
54
+
55
+ this.updateButtonStates()
56
+ if (this.sortableValue && !this.sortableInstance) this.initializeSortable()
20
57
  }
21
58
 
22
59
  remove(event) {
23
60
  event.preventDefault()
24
61
 
62
+ if (this.currentCount <= this.minValue) {
63
+ this.dispatch("minimum-reached", {
64
+ detail: { minimum: this.minValue, current: this.currentCount }
65
+ })
66
+ return
67
+ }
68
+
25
69
  const wrapper = event.currentTarget.closest(`.${this.wrapperClassValue}`)
26
70
  if (!wrapper) return
27
71
 
28
- // Dispatch cancelable before-remove event
29
72
  const beforeEvent = this.dispatch("before-remove", {
30
73
  cancelable: true,
31
74
  detail: { wrapper }
@@ -36,27 +79,23 @@ export default class extends Controller {
36
79
  const destroyInput = wrapper.querySelector("input[name*='_destroy']")
37
80
 
38
81
  if (destroyInput) {
39
- // Persisted record - hide and mark for destruction
40
82
  destroyInput.value = "true"
41
83
  wrapper.style.display = "none"
42
84
  } else {
43
- // New record - remove from DOM
44
85
  wrapper.remove()
45
86
  }
46
87
 
47
88
  this.dispatch("after-remove", { detail: { wrapper } })
89
+ this.updateButtonStates()
48
90
  }
49
91
 
50
- // Private
51
-
52
- #insertFields(template, insertion, targetSelector, trigger) {
92
+ insertFields(template, insertion, targetSelector, trigger) {
53
93
  const newId = new Date().getTime()
54
94
  const content = template.replace(/NEW_RECORD/g, newId)
55
95
 
56
96
  const fragment = document.createRange().createContextualFragment(content)
57
97
  const wrapper = fragment.firstElementChild
58
98
 
59
- // Dispatch cancelable before-add event
60
99
  const beforeEvent = this.dispatch("before-add", {
61
100
  cancelable: true,
62
101
  detail: { wrapper }
@@ -78,10 +117,117 @@ export default class extends Controller {
78
117
  case "prepend":
79
118
  container.prepend(fragment)
80
119
  break
81
- default: // "before"
120
+ default:
82
121
  trigger.before(fragment)
83
122
  }
84
123
 
85
124
  this.dispatch("after-add", { detail: { wrapper } })
86
125
  }
126
+
127
+ updateButtonStates() {
128
+ const atMax = this.currentCount >= this.maxValue
129
+ const atMin = this.currentCount <= this.minValue
130
+
131
+ this.addButtons.forEach(button => {
132
+ this.applyLimitState(button, atMax)
133
+ })
134
+
135
+ this.removeButtons.forEach(button => {
136
+ const wrapper = button.closest(`.${this.wrapperClassValue}`)
137
+ if (wrapper && wrapper.style.display !== "none") {
138
+ this.applyLimitState(button, atMin)
139
+ }
140
+ })
141
+ }
142
+
143
+ applyLimitState(button, isAtLimit) {
144
+ switch (this.limitBehaviorValue) {
145
+ case "hide":
146
+ button.style.display = isAtLimit ? "none" : ""
147
+ button.disabled = false
148
+ break
149
+ case "error":
150
+ button.disabled = false
151
+ button.style.display = ""
152
+ break
153
+ default: // "disable"
154
+ button.disabled = isAtLimit
155
+ button.style.display = ""
156
+ }
157
+ }
158
+
159
+ // Drag & Drop Sorting
160
+
161
+ initializeSortable() {
162
+ if (typeof Sortable === 'undefined') {
163
+ console.warn('hotwire_nested_form: SortableJS not found. Install it for drag & drop sorting: https://sortablejs.github.io/Sortable/')
164
+ return
165
+ }
166
+
167
+ const container = this.findSortableContainer()
168
+ if (!container) return
169
+
170
+ this.sortableInstance = Sortable.create(container, {
171
+ animation: 150,
172
+ handle: this.sortHandleValue || null,
173
+ draggable: `.${this.wrapperClassValue}`,
174
+ ghostClass: 'nested-form-drag-ghost',
175
+ chosenClass: 'nested-form-dragging',
176
+ onStart: (evt) => this.onSortStart(evt),
177
+ onEnd: (evt) => this.onSortEnd(evt)
178
+ })
179
+ }
180
+
181
+ findSortableContainer() {
182
+ // Look for common container patterns
183
+ const selectors = ['#tasks', '#items', '[data-nested-form-target="container"]']
184
+ for (const selector of selectors) {
185
+ const container = this.element.querySelector(selector)
186
+ if (container) return container
187
+ }
188
+ // Fallback: find first element containing nested-fields
189
+ const firstField = this.element.querySelector(`.${this.wrapperClassValue}`)
190
+ return firstField ? firstField.parentElement : this.element
191
+ }
192
+
193
+ onSortStart(evt) {
194
+ const beforeEvent = this.dispatch("before-sort", {
195
+ cancelable: true,
196
+ detail: { item: evt.item, oldIndex: evt.oldIndex }
197
+ })
198
+
199
+ if (beforeEvent.defaultPrevented) {
200
+ this.sortableInstance.option("disabled", true)
201
+ setTimeout(() => this.sortableInstance.option("disabled", false), 0)
202
+ }
203
+ }
204
+
205
+ onSortEnd(evt) {
206
+ this.updatePositions()
207
+
208
+ this.dispatch("after-sort", {
209
+ detail: {
210
+ item: evt.item,
211
+ oldIndex: evt.oldIndex,
212
+ newIndex: evt.newIndex
213
+ }
214
+ })
215
+
216
+ this.updateButtonStates()
217
+ }
218
+
219
+ updatePositions() {
220
+ const items = this.element.querySelectorAll(
221
+ `.${this.wrapperClassValue}:not([style*="display: none"])`
222
+ )
223
+
224
+ items.forEach((item, index) => {
225
+ const positionInput = item.querySelector(
226
+ `input[name*="[${this.positionFieldValue}]"]`
227
+ )
228
+ if (positionInput) {
229
+ positionInput.value = index + 1
230
+ }
231
+ })
232
+ }
87
233
  }
@@ -11,8 +11,19 @@ module HotwireNestedForm
11
11
  builder_class.include?('SimpleForm')
12
12
  end
13
13
 
14
+ def formtastic?(form_builder)
15
+ return false unless form_builder
16
+
17
+ builder_class = form_builder.class.name.to_s
18
+ builder_class.include?('Formtastic')
19
+ end
20
+
14
21
  def simple_form_available?
15
22
  defined?(::SimpleForm) ? true : false
16
23
  end
24
+
25
+ def formtastic_available?
26
+ defined?(::Formtastic) ? true : false
27
+ end
17
28
  end
18
29
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HotwireNestedForm
4
- VERSION = '1.1.0'
4
+ VERSION = '1.3.0'
5
5
  end
data/npm/README.md CHANGED
@@ -1,13 +1,13 @@
1
- # @hotwire-nested-form/stimulus
1
+ # hotwire-nested-form-stimulus
2
2
 
3
3
  A Stimulus controller for dynamic nested forms. Add and remove nested form fields with ease.
4
4
 
5
5
  ## Installation
6
6
 
7
7
  ```bash
8
- npm install @hotwire-nested-form/stimulus
8
+ npm install hotwire-nested-form-stimulus
9
9
  # or
10
- yarn add @hotwire-nested-form/stimulus
10
+ yarn add hotwire-nested-form-stimulus
11
11
  ```
12
12
 
13
13
  ## Usage
@@ -16,7 +16,7 @@ yarn add @hotwire-nested-form/stimulus
16
16
 
17
17
  ```javascript
18
18
  import { Application } from "@hotwired/stimulus"
19
- import NestedFormController from "@hotwire-nested-form/stimulus"
19
+ import NestedFormController from "hotwire-nested-form-stimulus"
20
20
 
21
21
  const application = Application.start()
22
22
  application.register("nested-form", NestedFormController)
@@ -47,6 +47,57 @@ application.register("nested-form", NestedFormController)
47
47
  | `data-count` | Number of fields to add per click | `1` |
48
48
  | `data-target` | CSS selector for insertion container | Parent element |
49
49
 
50
+ ### Min/Max Limits
51
+
52
+ ```html
53
+ <div data-controller="nested-form"
54
+ data-nested-form-min-value="1"
55
+ data-nested-form-max-value="5"
56
+ data-nested-form-limit-behavior-value="disable">
57
+ <!-- fields here -->
58
+ </div>
59
+ ```
60
+
61
+ | Attribute | Description | Default |
62
+ |-----------|-------------|---------|
63
+ | `data-nested-form-min-value` | Minimum items required | `0` |
64
+ | `data-nested-form-max-value` | Maximum items allowed | unlimited |
65
+ | `data-nested-form-limit-behavior-value` | `"disable"`, `"hide"`, or `"error"` | `"disable"` |
66
+
67
+ ### Drag & Drop Sorting
68
+
69
+ Requires [SortableJS](https://sortablejs.github.io/Sortable/):
70
+
71
+ ```bash
72
+ npm install sortablejs
73
+ ```
74
+
75
+ ```javascript
76
+ import Sortable from 'sortablejs'
77
+ window.Sortable = Sortable
78
+ ```
79
+
80
+ ```html
81
+ <div data-controller="nested-form"
82
+ data-nested-form-sortable-value="true"
83
+ data-nested-form-sort-handle-value=".drag-handle">
84
+
85
+ <div id="items">
86
+ <div class="nested-fields">
87
+ <input type="hidden" name="items[][position]" value="1">
88
+ <span class="drag-handle">☰</span>
89
+ <!-- other fields -->
90
+ </div>
91
+ </div>
92
+ </div>
93
+ ```
94
+
95
+ | Attribute | Default | Description |
96
+ |-----------|---------|-------------|
97
+ | `data-nested-form-sortable-value` | `false` | Enable sorting |
98
+ | `data-nested-form-position-field-value` | `"position"` | Position field name |
99
+ | `data-nested-form-sort-handle-value` | (none) | Drag handle selector |
100
+
50
101
  ### Events
51
102
 
52
103
  | Event | Cancelable | Detail |
@@ -55,6 +106,10 @@ application.register("nested-form", NestedFormController)
55
106
  | `nested-form:after-add` | No | `{ wrapper }` |
56
107
  | `nested-form:before-remove` | Yes | `{ wrapper }` |
57
108
  | `nested-form:after-remove` | No | `{ wrapper }` |
109
+ | `nested-form:limit-reached` | No | `{ limit, current }` |
110
+ | `nested-form:minimum-reached` | No | `{ minimum, current }` |
111
+ | `nested-form:before-sort` | Yes | `{ item, oldIndex }` |
112
+ | `nested-form:after-sort` | No | `{ item, oldIndex, newIndex }` |
58
113
 
59
114
  ### Example: Listen for Events
60
115
 
data/npm/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "name": "@hotwire-nested-form/stimulus",
3
- "version": "1.1.0",
2
+ "name": "hotwire-nested-form-stimulus",
3
+ "version": "1.3.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",
@@ -2,25 +2,70 @@ import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  export default class extends Controller {
4
4
  static values = {
5
- wrapperClass: { type: String, default: "nested-fields" }
5
+ wrapperClass: { type: String, default: "nested-fields" },
6
+ min: { type: Number, default: 0 },
7
+ max: { type: Number, default: 999999 },
8
+ limitBehavior: { type: String, default: "disable" },
9
+ sortable: { type: Boolean, default: false },
10
+ positionField: { type: String, default: "position" },
11
+ sortHandle: { type: String, default: "" }
12
+ }
13
+
14
+ connect() {
15
+ this.updateButtonStates()
16
+ if (this.sortableValue) this.initializeSortable()
17
+ }
18
+
19
+ disconnect() {
20
+ if (this.sortableInstance) this.sortableInstance.destroy()
21
+ }
22
+
23
+ get currentCount() {
24
+ return this.element.querySelectorAll(`.${this.wrapperClassValue}:not([style*="display: none"])`).length
25
+ }
26
+
27
+ get addButtons() {
28
+ return this.element.querySelectorAll('[data-action*="nested-form#add"]')
29
+ }
30
+
31
+ get removeButtons() {
32
+ return this.element.querySelectorAll('[data-action*="nested-form#remove"]')
6
33
  }
7
34
 
8
35
  add(event) {
9
36
  event.preventDefault()
10
37
 
38
+ if (this.currentCount >= this.maxValue) {
39
+ this.dispatch("limit-reached", {
40
+ detail: { limit: this.maxValue, current: this.currentCount }
41
+ })
42
+ return
43
+ }
44
+
11
45
  const template = event.currentTarget.dataset.template
12
46
  const insertion = event.currentTarget.dataset.insertion || "before"
13
47
  const targetSelector = event.currentTarget.dataset.target
14
48
  const count = parseInt(event.currentTarget.dataset.count) || 1
15
49
 
16
50
  for (let i = 0; i < count; i++) {
51
+ if (this.currentCount >= this.maxValue) break
17
52
  this.insertFields(template, insertion, targetSelector, event.currentTarget)
18
53
  }
54
+
55
+ this.updateButtonStates()
56
+ if (this.sortableValue && !this.sortableInstance) this.initializeSortable()
19
57
  }
20
58
 
21
59
  remove(event) {
22
60
  event.preventDefault()
23
61
 
62
+ if (this.currentCount <= this.minValue) {
63
+ this.dispatch("minimum-reached", {
64
+ detail: { minimum: this.minValue, current: this.currentCount }
65
+ })
66
+ return
67
+ }
68
+
24
69
  const wrapper = event.currentTarget.closest(`.${this.wrapperClassValue}`)
25
70
  if (!wrapper) return
26
71
 
@@ -41,6 +86,7 @@ export default class extends Controller {
41
86
  }
42
87
 
43
88
  this.dispatch("after-remove", { detail: { wrapper } })
89
+ this.updateButtonStates()
44
90
  }
45
91
 
46
92
  insertFields(template, insertion, targetSelector, trigger) {
@@ -77,4 +123,111 @@ export default class extends Controller {
77
123
 
78
124
  this.dispatch("after-add", { detail: { wrapper } })
79
125
  }
126
+
127
+ updateButtonStates() {
128
+ const atMax = this.currentCount >= this.maxValue
129
+ const atMin = this.currentCount <= this.minValue
130
+
131
+ this.addButtons.forEach(button => {
132
+ this.applyLimitState(button, atMax)
133
+ })
134
+
135
+ this.removeButtons.forEach(button => {
136
+ const wrapper = button.closest(`.${this.wrapperClassValue}`)
137
+ if (wrapper && wrapper.style.display !== "none") {
138
+ this.applyLimitState(button, atMin)
139
+ }
140
+ })
141
+ }
142
+
143
+ applyLimitState(button, isAtLimit) {
144
+ switch (this.limitBehaviorValue) {
145
+ case "hide":
146
+ button.style.display = isAtLimit ? "none" : ""
147
+ button.disabled = false
148
+ break
149
+ case "error":
150
+ button.disabled = false
151
+ button.style.display = ""
152
+ break
153
+ default: // "disable"
154
+ button.disabled = isAtLimit
155
+ button.style.display = ""
156
+ }
157
+ }
158
+
159
+ // Drag & Drop Sorting
160
+
161
+ initializeSortable() {
162
+ if (typeof Sortable === 'undefined') {
163
+ console.warn('hotwire_nested_form: SortableJS not found. Install it for drag & drop sorting: https://sortablejs.github.io/Sortable/')
164
+ return
165
+ }
166
+
167
+ const container = this.findSortableContainer()
168
+ if (!container) return
169
+
170
+ this.sortableInstance = Sortable.create(container, {
171
+ animation: 150,
172
+ handle: this.sortHandleValue || null,
173
+ draggable: `.${this.wrapperClassValue}`,
174
+ ghostClass: 'nested-form-drag-ghost',
175
+ chosenClass: 'nested-form-dragging',
176
+ onStart: (evt) => this.onSortStart(evt),
177
+ onEnd: (evt) => this.onSortEnd(evt)
178
+ })
179
+ }
180
+
181
+ findSortableContainer() {
182
+ // Look for common container patterns
183
+ const selectors = ['#tasks', '#items', '[data-nested-form-target="container"]']
184
+ for (const selector of selectors) {
185
+ const container = this.element.querySelector(selector)
186
+ if (container) return container
187
+ }
188
+ // Fallback: find first element containing nested-fields
189
+ const firstField = this.element.querySelector(`.${this.wrapperClassValue}`)
190
+ return firstField ? firstField.parentElement : this.element
191
+ }
192
+
193
+ onSortStart(evt) {
194
+ const beforeEvent = this.dispatch("before-sort", {
195
+ cancelable: true,
196
+ detail: { item: evt.item, oldIndex: evt.oldIndex }
197
+ })
198
+
199
+ if (beforeEvent.defaultPrevented) {
200
+ this.sortableInstance.option("disabled", true)
201
+ setTimeout(() => this.sortableInstance.option("disabled", false), 0)
202
+ }
203
+ }
204
+
205
+ onSortEnd(evt) {
206
+ this.updatePositions()
207
+
208
+ this.dispatch("after-sort", {
209
+ detail: {
210
+ item: evt.item,
211
+ oldIndex: evt.oldIndex,
212
+ newIndex: evt.newIndex
213
+ }
214
+ })
215
+
216
+ this.updateButtonStates()
217
+ }
218
+
219
+ updatePositions() {
220
+ const items = this.element.querySelectorAll(
221
+ `.${this.wrapperClassValue}:not([style*="display: none"])`
222
+ )
223
+
224
+ items.forEach((item, index) => {
225
+ const positionInput = item.querySelector(
226
+ `input[name*="[${this.positionFieldValue}]"]`
227
+ )
228
+ if (positionInput) {
229
+ positionInput.value = index + 1
230
+ }
231
+ })
232
+ }
80
233
  }
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.1.0
4
+ version: 1.3.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