hotwire_nested_form 1.3.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 +38 -0
- data/Gemfile.lock +1 -1
- data/README.md +221 -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 +177 -3
- data/lib/hotwire_nested_form/helpers/add_association.rb +17 -9
- 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 +102 -3
- data/npm/css/animations.css +46 -0
- data/npm/package.json +3 -2
- data/npm/src/nested_form_controller.js +177 -3
- metadata +5 -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,44 @@ 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
|
+
|
|
26
|
+
## [1.4.0] - 2026-02-06
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
- Add/remove animations with CSS transitions
|
|
30
|
+
- `data-nested-form-animation-value` - animation type: `"fade"`, `"slide"`, or `""` (none)
|
|
31
|
+
- `data-nested-form-animation-duration-value` - duration in ms (default: 300)
|
|
32
|
+
- CSS classes: `nested-form-enter`, `nested-form-enter-active`, `nested-form-exit-active`
|
|
33
|
+
- Optional animation stylesheet: `hotwire_nested_form/animations.css`
|
|
34
|
+
- Install with: `rails g hotwire_nested_form:install --animations`
|
|
35
|
+
- NPM users: `import "hotwire-nested-form-stimulus/css/animations.css"`
|
|
36
|
+
- Deep nesting (multi-level nested forms)
|
|
37
|
+
- Association-specific placeholders (`NEW_TASK_RECORD`, `NEW_SUBTASK_RECORD`) prevent collisions
|
|
38
|
+
- Each `link_to_add_association` automatically generates unique placeholders per association
|
|
39
|
+
- `<template>` tags for template storage (replaces `data-template` attribute for reliable deep nesting)
|
|
40
|
+
- Full backward compatibility - single-level forms work unchanged
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
- Template HTML now stored in `<template>` tags instead of `data-template` attributes
|
|
44
|
+
- `link_to_add_association` outputs `<template>` + `<a>` tag pair
|
|
45
|
+
- Controller `remove()` refactored into `remove()` + `removeElement()` for animation support
|
|
46
|
+
- Added `getTemplate()` method for flexible template lookup
|
|
47
|
+
|
|
10
48
|
## [1.3.0] - 2026-02-06
|
|
11
49
|
|
|
12
50
|
### Added
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
|
@@ -272,6 +272,200 @@ 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
|
+
|
|
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
|
+
|
|
275
469
|
## NPM Package (JavaScript-only)
|
|
276
470
|
|
|
277
471
|
For non-Rails projects using Stimulus, install via npm:
|
|
@@ -358,6 +552,31 @@ link_to_remove_association(name, form, options = {}, &block)
|
|
|
358
552
|
<% end %>
|
|
359
553
|
```
|
|
360
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
|
+
|
|
361
580
|
## JavaScript Events
|
|
362
581
|
|
|
363
582
|
| Event | Cancelable | Detail | When |
|
|
@@ -370,6 +589,8 @@ link_to_remove_association(name, form, options = {}, &block)
|
|
|
370
589
|
| `nested-form:minimum-reached` | No | `{ minimum, current }` | When min limit reached |
|
|
371
590
|
| `nested-form:before-sort` | Yes | `{ item, oldIndex }` | Before drag starts |
|
|
372
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 |
|
|
373
594
|
|
|
374
595
|
**Usage Examples:**
|
|
375
596
|
|
|
@@ -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,16 +8,24 @@ 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 },
|
|
14
|
+
a11y: { type: Boolean, default: true }
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
connect() {
|
|
18
|
+
if (this.a11yValue) this.setupAccessibility()
|
|
15
19
|
this.updateButtonStates()
|
|
16
20
|
if (this.sortableValue) this.initializeSortable()
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
disconnect() {
|
|
20
24
|
if (this.sortableInstance) this.sortableInstance.destroy()
|
|
25
|
+
if (this.liveRegion) {
|
|
26
|
+
this.liveRegion.remove()
|
|
27
|
+
this.liveRegion = null
|
|
28
|
+
}
|
|
21
29
|
}
|
|
22
30
|
|
|
23
31
|
get currentCount() {
|
|
@@ -42,7 +50,7 @@ export default class extends Controller {
|
|
|
42
50
|
return
|
|
43
51
|
}
|
|
44
52
|
|
|
45
|
-
const template = event.currentTarget
|
|
53
|
+
const template = this.getTemplate(event.currentTarget)
|
|
46
54
|
const insertion = event.currentTarget.dataset.insertion || "before"
|
|
47
55
|
const targetSelector = event.currentTarget.dataset.target
|
|
48
56
|
const count = parseInt(event.currentTarget.dataset.count) || 1
|
|
@@ -76,6 +84,14 @@ export default class extends Controller {
|
|
|
76
84
|
|
|
77
85
|
if (beforeEvent.defaultPrevented) return
|
|
78
86
|
|
|
87
|
+
if (this.animationValue) {
|
|
88
|
+
this.animateOut(wrapper, () => this.removeElement(wrapper))
|
|
89
|
+
} else {
|
|
90
|
+
this.removeElement(wrapper)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
removeElement(wrapper) {
|
|
79
95
|
const destroyInput = wrapper.querySelector("input[name*='_destroy']")
|
|
80
96
|
|
|
81
97
|
if (destroyInput) {
|
|
@@ -87,11 +103,31 @@ export default class extends Controller {
|
|
|
87
103
|
|
|
88
104
|
this.dispatch("after-remove", { detail: { wrapper } })
|
|
89
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
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
getTemplate(trigger) {
|
|
115
|
+
// Prefer <template> tag (handles deep nesting), fall back to data-template
|
|
116
|
+
const placeholder = trigger.dataset.placeholder
|
|
117
|
+
if (placeholder) {
|
|
118
|
+
const templateEl = this.element.querySelector(
|
|
119
|
+
`template[data-nested-form-template="${placeholder}"]`
|
|
120
|
+
)
|
|
121
|
+
if (templateEl) return templateEl.innerHTML
|
|
122
|
+
}
|
|
123
|
+
return trigger.dataset.template
|
|
90
124
|
}
|
|
91
125
|
|
|
92
126
|
insertFields(template, insertion, targetSelector, trigger) {
|
|
93
127
|
const newId = new Date().getTime()
|
|
94
|
-
const
|
|
128
|
+
const placeholder = trigger.dataset.placeholder || "NEW_RECORD"
|
|
129
|
+
const regex = new RegExp(placeholder, "g")
|
|
130
|
+
const content = template.replace(regex, newId)
|
|
95
131
|
|
|
96
132
|
const fragment = document.createRange().createContextualFragment(content)
|
|
97
133
|
const wrapper = fragment.firstElementChild
|
|
@@ -122,6 +158,144 @@ export default class extends Controller {
|
|
|
122
158
|
}
|
|
123
159
|
|
|
124
160
|
this.dispatch("after-add", { detail: { wrapper } })
|
|
161
|
+
|
|
162
|
+
if (this.animationValue) {
|
|
163
|
+
this.animateIn(wrapper)
|
|
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
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Animations
|
|
275
|
+
|
|
276
|
+
animateIn(element) {
|
|
277
|
+
element.classList.add("nested-form-enter")
|
|
278
|
+
requestAnimationFrame(() => {
|
|
279
|
+
requestAnimationFrame(() => {
|
|
280
|
+
element.classList.add("nested-form-enter-active")
|
|
281
|
+
setTimeout(() => {
|
|
282
|
+
element.classList.remove("nested-form-enter", "nested-form-enter-active")
|
|
283
|
+
}, this.animationDurationValue)
|
|
284
|
+
})
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
animateOut(element, callback) {
|
|
289
|
+
element.classList.add("nested-form-exit-active")
|
|
290
|
+
|
|
291
|
+
const done = () => {
|
|
292
|
+
element.classList.remove("nested-form-exit-active")
|
|
293
|
+
callback()
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
element.addEventListener("transitionend", done, { once: true })
|
|
297
|
+
// Fallback if transitionend doesn't fire
|
|
298
|
+
setTimeout(done, this.animationDurationValue + 50)
|
|
125
299
|
}
|
|
126
300
|
|
|
127
301
|
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
|
|
@@ -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
|
@@ -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,93 @@ 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
|
+
|
|
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
|
+
|
|
101
198
|
### Events
|
|
102
199
|
|
|
103
200
|
| Event | Cancelable | Detail |
|
|
@@ -110,6 +207,8 @@ window.Sortable = Sortable
|
|
|
110
207
|
| `nested-form:minimum-reached` | No | `{ minimum, current }` |
|
|
111
208
|
| `nested-form:before-sort` | Yes | `{ item, oldIndex }` |
|
|
112
209
|
| `nested-form:after-sort` | No | `{ item, oldIndex, newIndex }` |
|
|
210
|
+
| `nested-form:before-duplicate` | Yes | `{ source }` |
|
|
211
|
+
| `nested-form:after-duplicate` | No | `{ source, clone }` |
|
|
113
212
|
|
|
114
213
|
### Example: Listen for Events
|
|
115
214
|
|
|
@@ -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.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",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"files": [
|
|
9
|
-
"src"
|
|
9
|
+
"src",
|
|
10
|
+
"css"
|
|
10
11
|
],
|
|
11
12
|
"keywords": [
|
|
12
13
|
"stimulus",
|
|
@@ -8,16 +8,24 @@ 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 },
|
|
14
|
+
a11y: { type: Boolean, default: true }
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
connect() {
|
|
18
|
+
if (this.a11yValue) this.setupAccessibility()
|
|
15
19
|
this.updateButtonStates()
|
|
16
20
|
if (this.sortableValue) this.initializeSortable()
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
disconnect() {
|
|
20
24
|
if (this.sortableInstance) this.sortableInstance.destroy()
|
|
25
|
+
if (this.liveRegion) {
|
|
26
|
+
this.liveRegion.remove()
|
|
27
|
+
this.liveRegion = null
|
|
28
|
+
}
|
|
21
29
|
}
|
|
22
30
|
|
|
23
31
|
get currentCount() {
|
|
@@ -42,7 +50,7 @@ export default class extends Controller {
|
|
|
42
50
|
return
|
|
43
51
|
}
|
|
44
52
|
|
|
45
|
-
const template = event.currentTarget
|
|
53
|
+
const template = this.getTemplate(event.currentTarget)
|
|
46
54
|
const insertion = event.currentTarget.dataset.insertion || "before"
|
|
47
55
|
const targetSelector = event.currentTarget.dataset.target
|
|
48
56
|
const count = parseInt(event.currentTarget.dataset.count) || 1
|
|
@@ -76,6 +84,14 @@ export default class extends Controller {
|
|
|
76
84
|
|
|
77
85
|
if (beforeEvent.defaultPrevented) return
|
|
78
86
|
|
|
87
|
+
if (this.animationValue) {
|
|
88
|
+
this.animateOut(wrapper, () => this.removeElement(wrapper))
|
|
89
|
+
} else {
|
|
90
|
+
this.removeElement(wrapper)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
removeElement(wrapper) {
|
|
79
95
|
const destroyInput = wrapper.querySelector("input[name*='_destroy']")
|
|
80
96
|
|
|
81
97
|
if (destroyInput) {
|
|
@@ -87,11 +103,31 @@ export default class extends Controller {
|
|
|
87
103
|
|
|
88
104
|
this.dispatch("after-remove", { detail: { wrapper } })
|
|
89
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
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
getTemplate(trigger) {
|
|
115
|
+
// Prefer <template> tag (handles deep nesting), fall back to data-template
|
|
116
|
+
const placeholder = trigger.dataset.placeholder
|
|
117
|
+
if (placeholder) {
|
|
118
|
+
const templateEl = this.element.querySelector(
|
|
119
|
+
`template[data-nested-form-template="${placeholder}"]`
|
|
120
|
+
)
|
|
121
|
+
if (templateEl) return templateEl.innerHTML
|
|
122
|
+
}
|
|
123
|
+
return trigger.dataset.template
|
|
90
124
|
}
|
|
91
125
|
|
|
92
126
|
insertFields(template, insertion, targetSelector, trigger) {
|
|
93
127
|
const newId = new Date().getTime()
|
|
94
|
-
const
|
|
128
|
+
const placeholder = trigger.dataset.placeholder || "NEW_RECORD"
|
|
129
|
+
const regex = new RegExp(placeholder, "g")
|
|
130
|
+
const content = template.replace(regex, newId)
|
|
95
131
|
|
|
96
132
|
const fragment = document.createRange().createContextualFragment(content)
|
|
97
133
|
const wrapper = fragment.firstElementChild
|
|
@@ -122,6 +158,144 @@ export default class extends Controller {
|
|
|
122
158
|
}
|
|
123
159
|
|
|
124
160
|
this.dispatch("after-add", { detail: { wrapper } })
|
|
161
|
+
|
|
162
|
+
if (this.animationValue) {
|
|
163
|
+
this.animateIn(wrapper)
|
|
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
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Animations
|
|
275
|
+
|
|
276
|
+
animateIn(element) {
|
|
277
|
+
element.classList.add("nested-form-enter")
|
|
278
|
+
requestAnimationFrame(() => {
|
|
279
|
+
requestAnimationFrame(() => {
|
|
280
|
+
element.classList.add("nested-form-enter-active")
|
|
281
|
+
setTimeout(() => {
|
|
282
|
+
element.classList.remove("nested-form-enter", "nested-form-enter-active")
|
|
283
|
+
}, this.animationDurationValue)
|
|
284
|
+
})
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
animateOut(element, callback) {
|
|
289
|
+
element.classList.add("nested-form-exit-active")
|
|
290
|
+
|
|
291
|
+
const done = () => {
|
|
292
|
+
element.classList.remove("nested-form-exit-active")
|
|
293
|
+
callback()
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
element.addEventListener("transitionend", done, { once: true })
|
|
297
|
+
// Fallback if transitionend doesn't fire
|
|
298
|
+
setTimeout(done, this.animationDurationValue + 50)
|
|
125
299
|
}
|
|
126
300
|
|
|
127
301
|
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.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- BhumitBhadani
|
|
@@ -40,20 +40,24 @@ 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
|
|
50
52
|
- lib/hotwire_nested_form/form_builder_detector.rb
|
|
51
53
|
- lib/hotwire_nested_form/helpers.rb
|
|
52
54
|
- lib/hotwire_nested_form/helpers/add_association.rb
|
|
55
|
+
- lib/hotwire_nested_form/helpers/duplicate_association.rb
|
|
53
56
|
- lib/hotwire_nested_form/helpers/remove_association.rb
|
|
54
57
|
- lib/hotwire_nested_form/version.rb
|
|
55
58
|
- npm/.npmignore
|
|
56
59
|
- npm/README.md
|
|
60
|
+
- npm/css/animations.css
|
|
57
61
|
- npm/package.json
|
|
58
62
|
- npm/src/index.js
|
|
59
63
|
- npm/src/nested_form_controller.js
|