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 +4 -4
- data/CHANGELOG.md +16 -0
- data/Gemfile.lock +1 -1
- data/README.md +104 -0
- data/lib/generators/hotwire_nested_form/templates/nested_form_controller.js +120 -1
- data/lib/hotwire_nested_form/helpers/duplicate_association.rb +43 -0
- data/lib/hotwire_nested_form/helpers.rb +2 -0
- data/lib/hotwire_nested_form/version.rb +1 -1
- data/npm/README.md +31 -0
- data/npm/package.json +1 -1
- data/npm/src/nested_form_controller.js +120 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7033260639294c3125ab0debe416848833f6b55b04bf9f826ff6686f4d30c6c4
|
|
4
|
+
data.tar.gz: 6c93778b96b4b2fb757d84f71d1999a559c725528ee5835ed4e6906f1eb77366
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
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
|
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
|
@@ -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
|
+
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
|