hotwire_nested_form 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 173878e857afa1600895b0db444cb9c60785b587e79599c3bbc7759bc861f587
4
- data.tar.gz: 79c140402e129ae87e72525fee18ca125dfe0d2720ea6455c9b88b926996a19a
3
+ metadata.gz: 7033260639294c3125ab0debe416848833f6b55b04bf9f826ff6686f4d30c6c4
4
+ data.tar.gz: 6c93778b96b4b2fb757d84f71d1999a559c725528ee5835ed4e6906f1eb77366
5
5
  SHA512:
6
- metadata.gz: 5eb667ddcbf921baaa2f7b92cd618b40e8bf639fe344c9df3b696c8aee5f81abd9e611ca6c5e1f7cc671a894509ccabfa296584a319e28493bf4e9a22dcc7b30
7
- data.tar.gz: c644e0a39ef9e67ff612f0d640617ab22ebfe4fa46a3062178e44d8dad1d6d5d86457ecba53ab5664b965f86e8fd981fb7b19163765026be1eb7b51ec992608a
6
+ metadata.gz: 12f334f31a6b1e02abb519f1f780618c4749b95673c69fb59d4f65f47af74569d4a3688349d7f3513b4330d83b1caf5ec7e22af15737a04316be09e06c8cbff9
7
+ data.tar.gz: 981f73f9d25e634be708d81ee3e6e3f08569db17d4c269854b92ca045fbd641f5811294612bc75ea3d2d6b3ca86733862d3e61cb04d31d853acf2be917eae651
data/CHANGELOG.md CHANGED
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.5.0] - 2026-02-06
11
+
12
+ ### Added
13
+ - Accessibility (a11y) support enabled by default
14
+ - `data-nested-form-a11y-value` - enable/disable accessibility features (default: `true`)
15
+ - `role="group"` and `aria-label` automatically set on controller element
16
+ - Live region (`aria-live="polite"`) for screen reader announcements
17
+ - Focus management: first input focused after add, add button focused after remove
18
+ - Announcements: "Item N added.", "Item removed. N remaining.", "Item duplicated."
19
+ - Duplicate/Clone nested items
20
+ - `link_to_duplicate_association` Ruby helper
21
+ - `nested-form#duplicate` Stimulus action
22
+ - Clones item with field values, generates new index, clears persisted record ID
23
+ - Respects max limit
24
+ - New events: `nested-form:before-duplicate` (cancelable), `nested-form:after-duplicate`
25
+
10
26
  ## [1.4.0] - 2026-02-06
11
27
 
12
28
  ### Added
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- hotwire_nested_form (1.4.0)
4
+ hotwire_nested_form (1.5.0)
5
5
  rails (>= 7.0)
6
6
 
7
7
  GEM
data/README.md CHANGED
@@ -389,6 +389,83 @@ end
389
389
 
390
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
391
 
392
+ ## Accessibility
393
+
394
+ Accessibility features are **enabled by default**. The controller automatically:
395
+
396
+ - Sets `role="group"` and `aria-label` on the controller element
397
+ - Creates an `aria-live="polite"` region for screen reader announcements
398
+ - Focuses the first input when a new item is added
399
+ - Moves focus to the "Add" button when an item is removed
400
+ - Announces add/remove/duplicate actions to screen readers
401
+
402
+ ### Disabling Accessibility
403
+
404
+ ```erb
405
+ <div data-controller="nested-form"
406
+ data-nested-form-a11y-value="false">
407
+ <!-- fields -->
408
+ </div>
409
+ ```
410
+
411
+ ### Custom ARIA Label
412
+
413
+ Set your own `aria-label` and the controller will preserve it:
414
+
415
+ ```erb
416
+ <div data-controller="nested-form"
417
+ aria-label="Project tasks">
418
+ <!-- fields -->
419
+ </div>
420
+ ```
421
+
422
+ ## Duplicate/Clone
423
+
424
+ Duplicate an existing nested item (with its field values) as a starting point for a new item:
425
+
426
+ ### 1. Add Duplicate Button to Partial
427
+
428
+ ```erb
429
+ <%# _task_fields.html.erb %>
430
+ <div class="nested-fields">
431
+ <%= f.text_field :name %>
432
+ <%= link_to_duplicate_association "Duplicate", f, class: "btn-sm" %>
433
+ <%= link_to_remove_association "Remove", f %>
434
+ </div>
435
+ ```
436
+
437
+ ### 2. That's It!
438
+
439
+ Clicking "Duplicate" will:
440
+ - Clone the item with all current field values
441
+ - Generate a new unique index (so Rails creates a new record)
442
+ - Remove the persisted record ID (so it saves as a new record)
443
+ - Respect the max limit
444
+ - Animate the new item if animations are enabled
445
+ - Focus the first input of the clone
446
+ - Announce "Item duplicated." to screen readers
447
+
448
+ ### Duplicate Events
449
+
450
+ | Event | Cancelable | Detail |
451
+ |-------|------------|--------|
452
+ | `nested-form:before-duplicate` | Yes | `{ source }` |
453
+ | `nested-form:after-duplicate` | No | `{ source, clone }` |
454
+
455
+ ```javascript
456
+ // Customize the clone before it's inserted
457
+ document.addEventListener("nested-form:after-duplicate", (event) => {
458
+ const clone = event.detail.clone
459
+ // Clear specific fields in the clone
460
+ clone.querySelector("input[name*='description']").value = ""
461
+ })
462
+
463
+ // Prevent duplication conditionally
464
+ document.addEventListener("nested-form:before-duplicate", (event) => {
465
+ if (someCondition) event.preventDefault()
466
+ })
467
+ ```
468
+
392
469
  ## NPM Package (JavaScript-only)
393
470
 
394
471
  For non-Rails projects using Stimulus, install via npm:
@@ -475,6 +552,31 @@ link_to_remove_association(name, form, options = {}, &block)
475
552
  <% end %>
476
553
  ```
477
554
 
555
+ ### link_to_duplicate_association
556
+
557
+ ```ruby
558
+ link_to_duplicate_association(name, form, options = {}, &block)
559
+ ```
560
+
561
+ | Option | Type | Default | Description |
562
+ |--------|------|---------|-------------|
563
+ | (standard HTML options) | | | Passed to the `<a>` tag |
564
+
565
+ **Examples:**
566
+
567
+ ```erb
568
+ <%# Basic usage %>
569
+ <%= link_to_duplicate_association "Duplicate", f %>
570
+
571
+ <%# With HTML classes %>
572
+ <%= link_to_duplicate_association "Duplicate", f, class: "btn btn-sm" %>
573
+
574
+ <%# With block %>
575
+ <%= link_to_duplicate_association f do %>
576
+ <span class="icon">📋</span> Copy
577
+ <% end %>
578
+ ```
579
+
478
580
  ## JavaScript Events
479
581
 
480
582
  | Event | Cancelable | Detail | When |
@@ -487,6 +589,8 @@ link_to_remove_association(name, form, options = {}, &block)
487
589
  | `nested-form:minimum-reached` | No | `{ minimum, current }` | When min limit reached |
488
590
  | `nested-form:before-sort` | Yes | `{ item, oldIndex }` | Before drag starts |
489
591
  | `nested-form:after-sort` | No | `{ item, oldIndex, newIndex }` | After drop completes |
592
+ | `nested-form:before-duplicate` | Yes | `{ source }` | Before duplicating item |
593
+ | `nested-form:after-duplicate` | No | `{ source, clone }` | After item duplicated |
490
594
 
491
595
  **Usage Examples:**
492
596
 
@@ -10,16 +10,22 @@ export default class extends Controller {
10
10
  positionField: { type: String, default: "position" },
11
11
  sortHandle: { type: String, default: "" },
12
12
  animation: { type: String, default: "" },
13
- animationDuration: { type: Number, default: 300 }
13
+ animationDuration: { type: Number, default: 300 },
14
+ a11y: { type: Boolean, default: true }
14
15
  }
15
16
 
16
17
  connect() {
18
+ if (this.a11yValue) this.setupAccessibility()
17
19
  this.updateButtonStates()
18
20
  if (this.sortableValue) this.initializeSortable()
19
21
  }
20
22
 
21
23
  disconnect() {
22
24
  if (this.sortableInstance) this.sortableInstance.destroy()
25
+ if (this.liveRegion) {
26
+ this.liveRegion.remove()
27
+ this.liveRegion = null
28
+ }
23
29
  }
24
30
 
25
31
  get currentCount() {
@@ -97,6 +103,12 @@ export default class extends Controller {
97
103
 
98
104
  this.dispatch("after-remove", { detail: { wrapper } })
99
105
  this.updateButtonStates()
106
+
107
+ if (this.a11yValue) {
108
+ const addButton = this.element.querySelector('[data-action*="nested-form#add"]')
109
+ if (addButton) addButton.focus()
110
+ this.announce(`Item removed. ${this.currentCount} remaining.`)
111
+ }
100
112
  }
101
113
 
102
114
  getTemplate(trigger) {
@@ -150,6 +162,113 @@ export default class extends Controller {
150
162
  if (this.animationValue) {
151
163
  this.animateIn(wrapper)
152
164
  }
165
+
166
+ if (this.a11yValue) {
167
+ this.focusFirstInput(wrapper)
168
+ this.announce(`Item ${this.currentCount} added.`)
169
+ }
170
+ }
171
+
172
+ // Duplicate
173
+
174
+ duplicate(event) {
175
+ event.preventDefault()
176
+
177
+ if (this.currentCount >= this.maxValue) {
178
+ this.dispatch("limit-reached", {
179
+ detail: { limit: this.maxValue, current: this.currentCount }
180
+ })
181
+ return
182
+ }
183
+
184
+ const wrapper = event.currentTarget.closest(`.${this.wrapperClassValue}`)
185
+ if (!wrapper) return
186
+
187
+ const beforeEvent = this.dispatch("before-duplicate", {
188
+ cancelable: true,
189
+ detail: { source: wrapper }
190
+ })
191
+
192
+ if (beforeEvent.defaultPrevented) return
193
+
194
+ const clone = wrapper.cloneNode(true)
195
+ const newId = new Date().getTime()
196
+
197
+ this.prepareClone(clone, newId)
198
+
199
+ wrapper.after(clone)
200
+
201
+ this.dispatch("after-duplicate", {
202
+ detail: { source: wrapper, clone: clone }
203
+ })
204
+
205
+ if (this.animationValue) {
206
+ this.animateIn(clone)
207
+ }
208
+
209
+ this.updateButtonStates()
210
+ if (this.sortableValue) this.updatePositions()
211
+
212
+ if (this.a11yValue) {
213
+ this.focusFirstInput(clone)
214
+ this.announce("Item duplicated.")
215
+ }
216
+ }
217
+
218
+ prepareClone(clone, newId) {
219
+ const idInput = clone.querySelector("input[name*='[id]'][type='hidden']")
220
+ if (idInput) idInput.remove()
221
+
222
+ const destroyInput = clone.querySelector("input[name*='_destroy']")
223
+ if (destroyInput) destroyInput.value = "false"
224
+
225
+ const elements = clone.querySelectorAll("input, select, textarea, label")
226
+ elements.forEach(el => {
227
+ if (el.name) {
228
+ el.name = el.name.replace(/\[\d+\]/, `[${newId}]`)
229
+ }
230
+ if (el.id) {
231
+ el.id = el.id.replace(/_\d+_/, `_${newId}_`)
232
+ }
233
+ if (el.htmlFor) {
234
+ el.htmlFor = el.htmlFor.replace(/_\d+_/, `_${newId}_`)
235
+ }
236
+ })
237
+
238
+ clone.style.display = ""
239
+ }
240
+
241
+ // Accessibility
242
+
243
+ setupAccessibility() {
244
+ this.element.setAttribute("role", "group")
245
+ if (!this.element.getAttribute("aria-label")) {
246
+ this.element.setAttribute("aria-label", "Nested form fields")
247
+ }
248
+
249
+ this.liveRegion = document.createElement("div")
250
+ this.liveRegion.setAttribute("aria-live", "polite")
251
+ this.liveRegion.setAttribute("aria-atomic", "true")
252
+ this.liveRegion.classList.add("nested-form-live-region")
253
+ this.liveRegion.style.cssText = "position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)"
254
+ this.element.appendChild(this.liveRegion)
255
+ }
256
+
257
+ announce(message) {
258
+ if (!this.liveRegion) return
259
+ this.liveRegion.textContent = ""
260
+ requestAnimationFrame(() => {
261
+ this.liveRegion.textContent = message
262
+ })
263
+ }
264
+
265
+ focusFirstInput(wrapper) {
266
+ const focusable = wrapper.querySelector(
267
+ 'input:not([type="hidden"]), select, textarea'
268
+ )
269
+ if (focusable) {
270
+ setTimeout(() => focusable.focus(), 50)
271
+ }
153
272
  }
154
273
 
155
274
  // Animations
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HotwireNestedForm
4
+ module Helpers
5
+ module DuplicateAssociation
6
+ # Generates a link to duplicate a nested form item
7
+ #
8
+ # @param name [String] Link text (or use block)
9
+ # @param form [FormBuilder] Nested form object
10
+ # @param options [Hash] HTML attributes
11
+ # @yield Block for custom link content
12
+ # @return [String] HTML link element
13
+ #
14
+ # @example Basic usage
15
+ # <%= link_to_duplicate_association "Duplicate", f %>
16
+ #
17
+ # @example With block
18
+ # <%= link_to_duplicate_association f do %>
19
+ # <span>Copy</span>
20
+ # <% end %>
21
+ #
22
+ def link_to_duplicate_association(name = nil, form = nil, options = {}, &)
23
+ if block_given?
24
+ options = form || {}
25
+ form = name
26
+ name = capture(&)
27
+ end
28
+
29
+ raise ArgumentError, 'form is required' unless form
30
+
31
+ options = options.dup
32
+
33
+ data = options[:data] || {}
34
+ data[:action] = 'nested-form#duplicate'
35
+
36
+ options[:data] = data
37
+ options[:href] = '#'
38
+
39
+ content_tag(:a, name, options)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -2,10 +2,12 @@
2
2
 
3
3
  require_relative 'helpers/add_association'
4
4
  require_relative 'helpers/remove_association'
5
+ require_relative 'helpers/duplicate_association'
5
6
 
6
7
  module HotwireNestedForm
7
8
  module Helpers
8
9
  include AddAssociation
9
10
  include RemoveAssociation
11
+ include DuplicateAssociation
10
12
  end
11
13
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HotwireNestedForm
4
- VERSION = '1.4.0'
4
+ VERSION = '1.5.0'
5
5
  end
data/npm/README.md CHANGED
@@ -166,6 +166,35 @@ For multi-level nesting, use `<template>` tags and `data-placeholder` attributes
166
166
 
167
167
  The controller replaces only the matching placeholder per button, so nested templates stay intact.
168
168
 
169
+ ### Accessibility
170
+
171
+ Accessibility is **enabled by default**. The controller automatically:
172
+
173
+ - Sets `role="group"` and `aria-label` on the container
174
+ - Creates a live region for screen reader announcements
175
+ - Manages focus on add/remove/duplicate actions
176
+
177
+ Disable with:
178
+
179
+ ```html
180
+ <div data-controller="nested-form"
181
+ data-nested-form-a11y-value="false">
182
+ ```
183
+
184
+ ### Duplicate/Clone
185
+
186
+ Add a duplicate button to clone an existing item with its field values:
187
+
188
+ ```html
189
+ <div class="nested-fields">
190
+ <input name="items[][name]" value="Task A">
191
+ <a href="#" data-action="nested-form#duplicate">Duplicate</a>
192
+ <a href="#" data-action="nested-form#remove">Remove</a>
193
+ </div>
194
+ ```
195
+
196
+ The clone gets a new unique index and any persisted record ID is removed so it saves as a new record.
197
+
169
198
  ### Events
170
199
 
171
200
  | Event | Cancelable | Detail |
@@ -178,6 +207,8 @@ The controller replaces only the matching placeholder per button, so nested temp
178
207
  | `nested-form:minimum-reached` | No | `{ minimum, current }` |
179
208
  | `nested-form:before-sort` | Yes | `{ item, oldIndex }` |
180
209
  | `nested-form:after-sort` | No | `{ item, oldIndex, newIndex }` |
210
+ | `nested-form:before-duplicate` | Yes | `{ source }` |
211
+ | `nested-form:after-duplicate` | No | `{ source, clone }` |
181
212
 
182
213
  ### Example: Listen for Events
183
214
 
data/npm/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hotwire-nested-form-stimulus",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Stimulus controller for dynamic nested forms - works with Rails, React, Vue, or any Stimulus app",
5
5
  "main": "src/index.js",
6
6
  "module": "src/index.js",
@@ -10,16 +10,22 @@ export default class extends Controller {
10
10
  positionField: { type: String, default: "position" },
11
11
  sortHandle: { type: String, default: "" },
12
12
  animation: { type: String, default: "" },
13
- animationDuration: { type: Number, default: 300 }
13
+ animationDuration: { type: Number, default: 300 },
14
+ a11y: { type: Boolean, default: true }
14
15
  }
15
16
 
16
17
  connect() {
18
+ if (this.a11yValue) this.setupAccessibility()
17
19
  this.updateButtonStates()
18
20
  if (this.sortableValue) this.initializeSortable()
19
21
  }
20
22
 
21
23
  disconnect() {
22
24
  if (this.sortableInstance) this.sortableInstance.destroy()
25
+ if (this.liveRegion) {
26
+ this.liveRegion.remove()
27
+ this.liveRegion = null
28
+ }
23
29
  }
24
30
 
25
31
  get currentCount() {
@@ -97,6 +103,12 @@ export default class extends Controller {
97
103
 
98
104
  this.dispatch("after-remove", { detail: { wrapper } })
99
105
  this.updateButtonStates()
106
+
107
+ if (this.a11yValue) {
108
+ const addButton = this.element.querySelector('[data-action*="nested-form#add"]')
109
+ if (addButton) addButton.focus()
110
+ this.announce(`Item removed. ${this.currentCount} remaining.`)
111
+ }
100
112
  }
101
113
 
102
114
  getTemplate(trigger) {
@@ -150,6 +162,113 @@ export default class extends Controller {
150
162
  if (this.animationValue) {
151
163
  this.animateIn(wrapper)
152
164
  }
165
+
166
+ if (this.a11yValue) {
167
+ this.focusFirstInput(wrapper)
168
+ this.announce(`Item ${this.currentCount} added.`)
169
+ }
170
+ }
171
+
172
+ // Duplicate
173
+
174
+ duplicate(event) {
175
+ event.preventDefault()
176
+
177
+ if (this.currentCount >= this.maxValue) {
178
+ this.dispatch("limit-reached", {
179
+ detail: { limit: this.maxValue, current: this.currentCount }
180
+ })
181
+ return
182
+ }
183
+
184
+ const wrapper = event.currentTarget.closest(`.${this.wrapperClassValue}`)
185
+ if (!wrapper) return
186
+
187
+ const beforeEvent = this.dispatch("before-duplicate", {
188
+ cancelable: true,
189
+ detail: { source: wrapper }
190
+ })
191
+
192
+ if (beforeEvent.defaultPrevented) return
193
+
194
+ const clone = wrapper.cloneNode(true)
195
+ const newId = new Date().getTime()
196
+
197
+ this.prepareClone(clone, newId)
198
+
199
+ wrapper.after(clone)
200
+
201
+ this.dispatch("after-duplicate", {
202
+ detail: { source: wrapper, clone: clone }
203
+ })
204
+
205
+ if (this.animationValue) {
206
+ this.animateIn(clone)
207
+ }
208
+
209
+ this.updateButtonStates()
210
+ if (this.sortableValue) this.updatePositions()
211
+
212
+ if (this.a11yValue) {
213
+ this.focusFirstInput(clone)
214
+ this.announce("Item duplicated.")
215
+ }
216
+ }
217
+
218
+ prepareClone(clone, newId) {
219
+ const idInput = clone.querySelector("input[name*='[id]'][type='hidden']")
220
+ if (idInput) idInput.remove()
221
+
222
+ const destroyInput = clone.querySelector("input[name*='_destroy']")
223
+ if (destroyInput) destroyInput.value = "false"
224
+
225
+ const elements = clone.querySelectorAll("input, select, textarea, label")
226
+ elements.forEach(el => {
227
+ if (el.name) {
228
+ el.name = el.name.replace(/\[\d+\]/, `[${newId}]`)
229
+ }
230
+ if (el.id) {
231
+ el.id = el.id.replace(/_\d+_/, `_${newId}_`)
232
+ }
233
+ if (el.htmlFor) {
234
+ el.htmlFor = el.htmlFor.replace(/_\d+_/, `_${newId}_`)
235
+ }
236
+ })
237
+
238
+ clone.style.display = ""
239
+ }
240
+
241
+ // Accessibility
242
+
243
+ setupAccessibility() {
244
+ this.element.setAttribute("role", "group")
245
+ if (!this.element.getAttribute("aria-label")) {
246
+ this.element.setAttribute("aria-label", "Nested form fields")
247
+ }
248
+
249
+ this.liveRegion = document.createElement("div")
250
+ this.liveRegion.setAttribute("aria-live", "polite")
251
+ this.liveRegion.setAttribute("aria-atomic", "true")
252
+ this.liveRegion.classList.add("nested-form-live-region")
253
+ this.liveRegion.style.cssText = "position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)"
254
+ this.element.appendChild(this.liveRegion)
255
+ }
256
+
257
+ announce(message) {
258
+ if (!this.liveRegion) return
259
+ this.liveRegion.textContent = ""
260
+ requestAnimationFrame(() => {
261
+ this.liveRegion.textContent = message
262
+ })
263
+ }
264
+
265
+ focusFirstInput(wrapper) {
266
+ const focusable = wrapper.querySelector(
267
+ 'input:not([type="hidden"]), select, textarea'
268
+ )
269
+ if (focusable) {
270
+ setTimeout(() => focusable.focus(), 50)
271
+ }
153
272
  }
154
273
 
155
274
  // Animations
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.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - BhumitBhadani
@@ -52,6 +52,7 @@ files:
52
52
  - lib/hotwire_nested_form/form_builder_detector.rb
53
53
  - lib/hotwire_nested_form/helpers.rb
54
54
  - lib/hotwire_nested_form/helpers/add_association.rb
55
+ - lib/hotwire_nested_form/helpers/duplicate_association.rb
55
56
  - lib/hotwire_nested_form/helpers/remove_association.rb
56
57
  - lib/hotwire_nested_form/version.rb
57
58
  - npm/.npmignore