hibiki_rails 0.7.0 → 0.9.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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +138 -0
  3. data/app/assets/javascripts/hibiki.js +160 -9
  4. data/lib/generators/hibiki/rails/css_variant.rb +14 -2
  5. data/lib/generators/hibiki/rails/multiselect/templates/concern.rb.tt +28 -7
  6. data/lib/generators/hibiki/rails/multiselect_helpers.rb +1 -1
  7. data/lib/generators/hibiki/rails/multiselect_injections.rb +7 -2
  8. data/lib/generators/hibiki/rails/nested/USAGE +30 -0
  9. data/lib/generators/hibiki/rails/nested/nested_generator.rb +152 -0
  10. data/lib/generators/hibiki/rails/nested/templates/_fields.html.erb.tt +43 -0
  11. data/lib/generators/hibiki/rails/nested/templates/child_form.rb.tt +34 -0
  12. data/lib/generators/hibiki/rails/nested/templates/fields_component.rb.tt +70 -0
  13. data/lib/generators/hibiki/rails/nested/templates/position_migration.rb.tt +7 -0
  14. data/lib/generators/hibiki/rails/nested_helpers.rb +328 -0
  15. data/lib/generators/hibiki/rails/nested_injections.rb +641 -0
  16. data/lib/generators/hibiki/rails/scaffold/scaffold_generator.rb +2 -0
  17. data/lib/generators/hibiki/rails/scaffold_controller/scaffold_controller_generator.rb +3 -0
  18. data/lib/generators/hibiki/rails/scaffold_controller/templates/daisyui/views/_pagination.html.erb.tt +15 -9
  19. data/lib/generators/hibiki/rails/scaffold_controller/templates/none/views/_pagination.html.erb.tt +15 -9
  20. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/daisyui/views/pagination.rb.tt +12 -6
  21. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/none/views/pagination.rb.tt +12 -6
  22. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/controls.rb.tt +30 -6
  23. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/index.rb.tt +18 -3
  24. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/list.rb.tt +29 -4
  25. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/row.rb.tt +19 -5
  26. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/row_form.rb.tt +11 -11
  27. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/tailwind/views/pagination.rb.tt +12 -6
  28. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/channel.rb.tt +96 -12
  29. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/controller.rb.tt +3 -2
  30. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/form.rb.tt +3 -2
  31. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/query.rb.tt +46 -0
  32. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_controls.html.erb.tt +28 -6
  33. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_list.html.erb.tt +24 -2
  34. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_row.html.erb.tt +17 -4
  35. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_row_form.html.erb.tt +8 -7
  36. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/index.html.erb.tt +18 -3
  37. data/lib/generators/hibiki/rails/scaffold_controller/templates/tailwind/views/_pagination.html.erb.tt +15 -9
  38. data/lib/generators/hibiki/rails/scaffold_phlex_helpers.rb +15 -2
  39. data/lib/generators/hibiki/rails/scaffold_shared_views.rb +14 -1
  40. data/lib/generators/hibiki/rails/scaffold_view_helpers.rb +46 -23
  41. data/lib/hibiki/rails/channel.rb +23 -0
  42. data/lib/hibiki/rails/helpers.rb +24 -6
  43. data/lib/hibiki/rails/nested_actions.rb +95 -0
  44. data/lib/hibiki/rails/reactive_form.rb +172 -9
  45. data/lib/hibiki/rails/version.rb +1 -1
  46. data/lib/hibiki/rails.rb +1 -0
  47. metadata +10 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eb224598ce236ad6d2f55b9648fbcb4644a478e1c86db2839dc3648207cd0605
4
- data.tar.gz: 2c4c73dbc34338aca84fde08bf520e47dcc5920a7e4a6403ce9b4ee6d7ec62b6
3
+ metadata.gz: b661448cbe2b84a20e26f5cd0bffaf2cf7be8c755f0f1b9c692f77e9f5798430
4
+ data.tar.gz: b09c3b35032a6990ab08c17e424a147e9e667905a4f9e9378062d52583e213d6
5
5
  SHA512:
6
- metadata.gz: 5cace159e074313892f3736f5bed6220e1852fc71d85ee5b063af80c31471662f492d945ad57b5589543b54f43d2f4bac3dce3fdf8718fc0b8b1df18d0f5e756
7
- data.tar.gz: 99a518942cc899684e94951d88fea9a08524c83327ae61cc5a924356b16485b33a14cc51fc9fcac3d8dc67d71f96c41ef5f8915d0b97349bc4ed0f74db3de556
6
+ metadata.gz: dc4013a0867b606341dd9af0135a82ab9e5a8c6b5f5cabd788eea48e0f3b604a708d31aa21c044f8639a5f2951242986b710d893b968246d03703359d562235b
7
+ data.tar.gz: d407a4c966366e5b11d9db6eaf7e789dcd4e086ca028dad0b2bc9b69bc4ec8f6b110339e338894b51851aa8ace18f08ef2339ce9198cf7a9226dc48d11b8c592
data/CHANGELOG.md CHANGED
@@ -4,6 +4,144 @@ The gem and the npm package are released in lockstep and share these version
4
4
  numbers — `app/assets/javascripts/hibiki.js` is a single copy served both ways,
5
5
  so importmap and bundler apps always resolve identical client code.
6
6
 
7
+ ## 0.9.0 — 2026-08-17
8
+
9
+ ### Added
10
+
11
+ **`reactive_nested` — nested forms over `accepts_nested_attributes_for`.**
12
+ `reactive_nested :credits, "CreditForm"` on a ReactiveForm declares a signal
13
+ holding an array of child forms; the child class may itself declare
14
+ `reactive_nested`, so depth is composition — nothing counts levels. `#to_h`
15
+ serializes the tree as recursive `*_attributes` (with `id:` and `_destroy:`),
16
+ so `#commit` persists everything in the record's one save; a failed commit
17
+ distributes each child record's errors onto the matching child form, and
18
+ `dirty?` tracks child edits, adds, removes, and destroy-marks for free. New
19
+ instance API: `nested_add` / `nested_remove` (a persisted child is marked
20
+ `_destroy`, a new one leaves the array), `nested_key` (`"c<id>"` / `"n<seq>"`
21
+ — stable DOM identity across repaints), `mark_for_destruction`. The form
22
+ also unloads its nested associations after every commit attempt: a failed
23
+ save leaves AR-built children in the in-memory association, and without the
24
+ unload a later success would insert them twice.
25
+
26
+ **`Hibiki::Rails::NestedActions` — generic channel actions for nested
27
+ forms.** Opt-in include next to `Hibiki::Rails::Channel`: `nested_add`,
28
+ `nested_remove`, `nested_move` (reorder to an index among visible
29
+ siblings — with position stamped from array order, up/down controls are
30
+ all a reorderable list needs), and `nested_set_field`, addressing any node by a
31
+ `dom` + `path` payload (`"credits/c3/contributions/n1"` — association names
32
+ alternating with child keys, any depth). Every hop is gated against the form
33
+ classes' declarations, keys against live children, fields against
34
+ `hibiki_attributes`. The default form resolver understands the scaffold's
35
+ `@form`/`@editing_id` and `@new_form`/`@creating` ivars; override the private
36
+ `nested_form_for(dom)` for anything else. No client change — nested controls
37
+ name themselves `"#{path}/#{field}"` and ride the existing payload mechanics.
38
+
39
+ **`hibiki:rails:nested` — generate one parent→child edge of a nested form.**
40
+ `bin/rails g hibiki:rails:nested Song Credit`, then `... Credit Contribution`
41
+ for the next level — depth is composition, each run wires one edge. The child
42
+ model must exist and `belongs_to` the parent; attribute arguments only
43
+ reorder or subset what the schema already knows. Emits the child ReactiveForm
44
+ and a `_<child>_fields` partial (or Phlex component) with path-addressed
45
+ inputs, injects the ordered `has_many` + `accepts_nested_attributes_for`,
46
+ `reactive_nested`, the `NestedActions` include, preloads, and the classic
47
+ `fields_for` + `params.expect` degraded path into the full-page form. A
48
+ `position` column is detected for ordering (`--position=COLUMN` names one,
49
+ adding the migration; `--skip-position` opts out) and ordered edges get
50
+ up/down controls. Works against a scaffolded collection (root mode) or an
51
+ already-nested parent's fields partial (deep mode), on both view layers.
52
+
53
+ **`perform(action, payload)` is public API — and a `performOn` helper.**
54
+ The blessed seam for app JS (a drag library's drop handler, any third-party
55
+ widget) to fire actions on an island's graph through the island's OWN
56
+ subscription. Reach the controller instance with Stimulus's standard
57
+ `application.getControllerForElementAndIdentifier(islandEl, "hibiki")`, or
58
+ skip the incantation with the new export — `performOn(element, action,
59
+ payload)` finds the island containing `element` and performs through it,
60
+ Stimulus context not required. The return value is the contract: truthy (the
61
+ trip's sequence number) means the action was accepted — sent live, or queued
62
+ during the island's initial connect window — and a repaint is coming, so
63
+ leave the DOM as the user arranged it; `undefined` means it was dropped (the
64
+ island is offline, the socket turned out to be dead, or no island contains
65
+ the element) and the caller owns recovery: revert the gesture, or stand back
66
+ and let the next repaint self-heal. Nothing queues across an offline gap, on
67
+ purpose — a reconnect builds a fresh server-side graph, and replaying intent
68
+ formed against the old one is worse than dropping it. Hand-writing
69
+ `data-hibiki-*` attributes and subclassing `ChannelController` to reach an
70
+ existing island remain unsupported: the attributes are a private contract,
71
+ and a subclass opens a second subscription with a second graph nobody paints
72
+ from.
73
+
74
+ ### Fixed
75
+
76
+ **`perform` during an offline gap reported success while dropping the
77
+ payload.** Between a socket drop and the reconnect, `perform` returned the
78
+ trip's sequence number as if the action had been accepted, while sending
79
+ nothing and queueing nothing. Declared actions never noticed (the fallback
80
+ machinery gates on island state before performing), but with the return
81
+ value now public API the lie mattered: it returns `undefined` there, the
82
+ same dropped signal as a dead socket caught at send.
83
+
84
+ ## 0.8.0 — 2026-08-15
85
+
86
+ ### Added
87
+
88
+ **`on(..., fallback: true)` — progressive enhancement over native controls.**
89
+ A control's native behavior — a link's href, a form's `action=` — is its
90
+ degraded path. Only a `ready` island intercepts the gesture and performs the
91
+ channel action; while connecting, offline, or stalled the client stands aside
92
+ entirely and the browser does what the markup says. A dead-but-undetected
93
+ socket is caught too: when the subscription reports the send failed, the
94
+ client settles the trip and runs the native behavior by hand
95
+ (`form.submit()` / `location.assign`) — the gesture never left, so it cannot
96
+ double-fire. Two guarantees ride along: `confirm:` still gates the native
97
+ path while scripts run (a destructive submit must not slip past the dialog
98
+ just because the island is down), and before any native submit the client
99
+ freshens the form's `authenticity_token` from the `csrf-token` meta tag —
100
+ forms repainted by a channel are rendered without a session and carry no
101
+ token, so freshening is load-bearing, not an edge case. A truly script-free
102
+ page still holds its first-paint token and needs no help.
103
+
104
+ **`transmit_url` — mirror graph state into the address bar.**
105
+ `transmit_value`'s URL sibling: an equality-gated effect transmits `{ url: }`
106
+ and the client `history.replaceState`s the bar to it. Never `pushState` — the
107
+ URL is a mirror of graph state, not a history entry, so there is no popstate
108
+ choreography and Back leaves the page normally. Same-origin only; the client
109
+ resolves the URL against the page's own origin and refuses anything else.
110
+
111
+ **The scaffold now generates the whole degraded-path pattern.** The query
112
+ object grows a URL half — `from_params` and canonical `url_params` (defaults
113
+ omitted) — shared by the controller's first paint, the island's
114
+ subscribe-params seeding (the graph starts from the URL's state, so its first
115
+ broadcast repaints what the server painted instead of resetting to defaults),
116
+ the pagination hrefs, and the channel's `transmit_url`, which also mirrors an
117
+ open form's URL so a reload mid-edit lands on the standard edit page. The
118
+ controls become one GET form to the index: live, the controls fire channel
119
+ actions; dead, Enter, Apply, and the direction button submit natively and
120
+ `from_params` answers. Edit is a real link to the edit page, Destroy a real
121
+ `button_to` DELETE form, and the page control's links carry real hrefs — all
122
+ with `fallback: true`, in both view layers and every css variant.
123
+
124
+ **The scaffold's list gains an inline create form, on by default.** The New
125
+ link (now inside the island) opens a channel-owned create form at the top of
126
+ the list — its own `ReactiveForm` instance, so an open row edit and an open
127
+ create never fight over state — and degrades to the standard new page. The
128
+ shared row form is parameterized (`dom:`, `save_action:`, `cancel_action:`,
129
+ `field_action:`, `cancel_with:`) and serves both uses; `--skip-create` omits
130
+ the whole surface. Generated forms also gate live validation on `dirty?`, so
131
+ a freshly opened form paints clean instead of flagging every blank field.
132
+
133
+ **`hibiki:rails:multiselect` serves the inline create form too.** The
134
+ dropdown appears with the full option list and an empty selection; each
135
+ checkbox's toggle names the form it belongs to, so an open row edit and the
136
+ create form never cross-write, even side by side.
137
+
138
+ ### Changed
139
+
140
+ **Re-running the scaffold over a pre-0.8.0 app refreshes a same-styled shared
141
+ page control in place** — the regenerated list passes the new `url:` local,
142
+ which the old shared partial does not declare. A page control written under a
143
+ different `--css` style is still kept and noticed, never restyled.
144
+
7
145
  ## 0.7.0 — 2026-08-12
8
146
 
9
147
  ### Added
@@ -52,6 +52,11 @@
52
52
  // data-hibiki-debounce="250" ms to let the gesture settle
53
53
  // data-hibiki-confirm="Are you sure?" window.confirm gate
54
54
  // data-hibiki-reset="false" keep a submitted form's inputs
55
+ // data-hibiki-fallback="true" the control's native behavior
56
+ // (a link's navigation, a form's action=) is its fallback: while the
57
+ // island is `ready` the event is intercepted and only the channel
58
+ // action fires; in every other state the client stands aside and the
59
+ // browser does what the markup says
55
60
  // value sites data-hibiki-value="<name>" reactive-value placeholder;
56
61
  // the server's transmit_value message updates every match
57
62
  //
@@ -75,6 +80,36 @@
75
80
  // element entering the viewport). Everything that is not "which event"
76
81
  // is a sibling attribute, so the token grammar never has to grow.
77
82
  //
83
+ // App JS reaching the graph — the ONE public seam. A gesture that needs
84
+ // script (drag-and-drop, a third-party widget) fires its action through
85
+ // the island's OWN subscription: `perform(action, payload)` on the island
86
+ // controller instance is public API. Reach the instance with Stimulus's
87
+ // standard lookup —
88
+ //
89
+ // const islandEl = element.closest('[data-controller~="hibiki"]')
90
+ // const island = application.getControllerForElementAndIdentifier(islandEl, "hibiki")
91
+ // island?.perform("nested_move", { path, to })
92
+ //
93
+ // — or skip the incantation with the performOn export at the bottom of
94
+ // this file (works outside Stimulus too):
95
+ //
96
+ // import { performOn } from "hibiki-rails"
97
+ // performOn(element, "nested_move", { path, to })
98
+ //
99
+ // The return value is the whole contract: truthy (the trip's seq) means
100
+ // the action was ACCEPTED — sent live, or queued during the initial
101
+ // connect window — so a repaint is coming and the caller should leave the
102
+ // DOM as the user arranged it (the morph lands as a visual no-op). Falsy
103
+ // (undefined) means it was DROPPED — the island is offline, or the socket
104
+ // turned out to be dead at send — and the caller owns recovery: revert
105
+ // the gesture, or stand back and let the next repaint self-heal. Nothing
106
+ // queues across an offline gap on purpose (a reconnect builds a fresh
107
+ // server-side graph, so replayed intent would land on state it was not
108
+ // formed against). Everything else is NOT a seam: the data-hibiki-*
109
+ // attributes are private, and subclassing ChannelController to reach an
110
+ // existing island opens a SECOND subscription — a second server-side
111
+ // graph nobody paints from.
112
+ //
78
113
  // Register the generic controller under the identifier "hibiki" (the
79
114
  // helpers hardcode it):
80
115
  //
@@ -88,6 +123,13 @@ import { createConsumer } from "@rails/actioncable"
88
123
  // and go with the DOM, the socket stays.
89
124
  let consumer
90
125
 
126
+ // Live islands by root element, for performOn's ancestor walk — membership
127
+ // here, not an attribute probe, so the helper couples to neither the
128
+ // identifier string nor the wire attributes. Generic islands only:
129
+ // ChannelController subclasses have `this.perform`. WeakMap so a removed
130
+ // island pins nothing even if disconnect never ran.
131
+ const islands = new WeakMap()
132
+
91
133
  // camelCase Stimulus method name → snake_case Ruby channel action.
92
134
  const underscore = (name) => name.replace(/([A-Z])/g, "_$1").toLowerCase()
93
135
 
@@ -122,6 +164,27 @@ const formPayload = (form) => {
122
164
  return payload
123
165
  }
124
166
 
167
+ // Before a fallback form goes native, re-stamp its CSRF token from the
168
+ // page's csrf-token meta — which is first-paint fresh and session-valid.
169
+ // Server-side repaints render without a session (ApplicationController
170
+ // .render has none), so a repainted form embeds a stale token or none at
171
+ // all; and this only ever runs with scripts alive, which is exactly when
172
+ // the DOM may have been repainted. A truly script-free page still holds
173
+ // its first-paint token and never needed the help.
174
+ const freshenToken = (control) => {
175
+ if (!(control instanceof HTMLFormElement)) return
176
+ const meta = document.querySelector('meta[name="csrf-token"]')
177
+ if (!meta) return
178
+ let input = control.querySelector('input[name="authenticity_token"]')
179
+ if (!input) {
180
+ input = document.createElement("input")
181
+ input.type = "hidden"
182
+ input.name = "authenticity_token"
183
+ control.appendChild(input)
184
+ }
185
+ input.value = meta.content
186
+ }
187
+
125
188
  // The subclassable base: one channel subscription per controller element,
126
189
  // identified by a per-page-load cid (data-<identifier>-cid-value).
127
190
  export class ChannelController extends Controller {
@@ -212,13 +275,28 @@ export class ChannelController extends Controller {
212
275
  // ActionCable's own Subscription#perform already writes `action`.)
213
276
  //
214
277
  // Returns the seq so a caller that knows which control fired can attach
215
- // it; nobody has to.
278
+ // it; nobody has to. Returns undefined instead when the payload went
279
+ // nowhere: the socket turned out to be closed under a subscription still
280
+ // believed live, or the island was already offline. Public API on the
281
+ // island controller (the header's "App JS reaching the graph") — truthy
282
+ // = accepted, falsy = dropped and the caller owns recovery.
216
283
  perform(action, payload = {}) {
217
284
  const seq = ++this.seq
218
285
  payload.hbk = seq
219
286
  if (this.subscribed) {
220
287
  this.beginBusy(seq)
221
- this.subscription.perform(action, payload)
288
+ // Action Cable's Subscription#perform returns false when the socket
289
+ // is not open — the gap between the socket dying and the connection
290
+ // monitor noticing, during which `subscribed` still says live. The
291
+ // frame went nowhere: settle rather than letting the trip stall out
292
+ // at the ceiling, and stamp `offline` now instead of when the
293
+ // monitor catches up. The monitor still owns reconnecting; its
294
+ // `connected` callback restores `ready` exactly as after a real gap.
295
+ if (this.subscription.perform(action, payload) === false) {
296
+ this.settle(seq)
297
+ this.linkClosed()
298
+ return undefined
299
+ }
222
300
  return seq
223
301
  }
224
302
  // Queue rather than drop while the subscription is still coming up.
@@ -237,8 +315,11 @@ export class ChannelController extends Controller {
237
315
  if (!this.connectedOnce) {
238
316
  this.beginBusy(seq)
239
317
  this.queued.push([action, payload])
318
+ return seq
240
319
  }
241
- return seq
320
+ // The offline gap: dropped, and the return says so — a truthy seq here
321
+ // would tell a public caller a repaint is coming when none is.
322
+ return undefined
242
323
  }
243
324
 
244
325
  // ActionCable's `connected`, i.e. the server confirmed the subscription.
@@ -368,7 +449,7 @@ export class ChannelController extends Controller {
368
449
  this.received(data)
369
450
  }
370
451
 
371
- // Server → DOM (transmit transport). Two message shapes:
452
+ // Server → DOM (transmit transport). Three message shapes:
372
453
  //
373
454
  // { value: { name, text } } — a reactive value (transmit_value): write
374
455
  // the text into every [data-hibiki-value=name] placeholder, document-
@@ -376,11 +457,13 @@ export class ChannelController extends Controller {
376
457
  // textContent assignment keeps values text-only and preserves each
377
458
  // site's own tag/classes, so per-placeholder styling survives updates.
378
459
  //
460
+ // { url } — mirror graph state into the address bar (transmit_url).
461
+ //
379
462
  // { html } — a fragment: swap it in by its root id.
380
463
  //
381
464
  // Anything else is not ours to interpret. Subclasses may override, but
382
465
  // should call super (or handle `value`) to keep reactive values live.
383
- received({ html, value }) {
466
+ received({ html, value, url }) {
384
467
  if (value) {
385
468
  const selector = `[data-hibiki-value="${CSS.escape(value.name)}"]`
386
469
  for (const site of document.querySelectorAll(selector)) {
@@ -388,6 +471,7 @@ export class ChannelController extends Controller {
388
471
  }
389
472
  return
390
473
  }
474
+ if (url !== undefined) return this.replaceUrl(url)
391
475
  if (!html) return
392
476
  const template = document.createElement("template")
393
477
  template.innerHTML = html
@@ -396,6 +480,17 @@ export class ChannelController extends Controller {
396
480
  }
397
481
  }
398
482
 
483
+ // replaceState, never pushState: the URL is a mirror of graph state, not
484
+ // a history entry — Back needs no popstate handling and leaves the page
485
+ // normally. Same-origin only, so a channel can move the bar solely
486
+ // within its own app (replaceState would throw on a cross-origin URL;
487
+ // refusing keeps it silent and intentional).
488
+ replaceUrl(url) {
489
+ const resolved = new URL(url, window.location.href)
490
+ if (resolved.origin !== window.location.origin) return
491
+ history.replaceState(history.state, "", resolved)
492
+ }
493
+
399
494
  // `static channel = "..."` wins; otherwise infer Rails-style from the
400
495
  // identifier: "counter" → CounterChannel, "my-thing" → MyThingChannel.
401
496
  channelName() {
@@ -465,6 +560,7 @@ export default class HibikiController extends ChannelController {
465
560
  }
466
561
 
467
562
  async connect() {
563
+ islands.set(this.element, this)
468
564
  // Before the listeners, not after: a click delegated in the next
469
565
  // millisecond reaches perform(), which needs the busy map and the
470
566
  // queue to exist. This is also what stamps data-hibiki-state
@@ -528,6 +624,7 @@ export default class HibikiController extends ChannelController {
528
624
  }
529
625
 
530
626
  disconnect() {
627
+ islands.delete(this.element)
531
628
  for (const [type, handler] of this.listeners) {
532
629
  this.element.removeEventListener(type, handler)
533
630
  }
@@ -574,9 +671,27 @@ export default class HibikiController extends ChannelController {
574
671
  if (!token) return
575
672
  const action = token.slice(event.type.length + 2)
576
673
 
577
- // Before the confirm, not after: declining must not let the form
578
- // navigate away.
579
- if (event.type === "submit") event.preventDefault()
674
+ // A fallback control's native behavior IS the degraded path: unless
675
+ // the island is `ready`, stand aside — no perform, no queueing — and
676
+ // the browser follows the href or submits the form to its own
677
+ // action=. Deliberately not the connect-window queue: a queued gesture
678
+ // renders nothing until the link comes up, while the control's
679
+ // destination answers immediately. Two touches before stepping back:
680
+ // a confirm: still gates the native behavior (scripts are running, so
681
+ // a destructive submit must not slip past the dialog), and a form's
682
+ // authenticity_token is freshened — server-rendered repaints carry no
683
+ // session, so their forms embed a stale token or none at all.
684
+ const fallback = "hibikiFallback" in control.dataset
685
+ if (fallback && this.state !== "ready") {
686
+ const message = control.dataset.hibikiConfirm
687
+ if (message && !window.confirm(message)) return event.preventDefault?.()
688
+ return freshenToken(control)
689
+ }
690
+
691
+ // Before the confirm, not after: declining must not let the form (or
692
+ // a fallback control's navigation) proceed. Optional call because the
693
+ // `visible` pseudo-event arrives as a plain object.
694
+ if (event.type === "submit" || fallback) event.preventDefault?.()
580
695
 
581
696
  const message = control.dataset.hibikiConfirm
582
697
  if (message && !window.confirm(message)) return
@@ -601,7 +716,15 @@ export default class HibikiController extends ChannelController {
601
716
  }
602
717
  // perform stamps `hbk` after this merge, so a field literally named hbk
603
718
  // loses to the seq rather than corrupting it.
604
- this.trackControl(this.perform(action, payload), control)
719
+ const seq = this.perform(action, payload)
720
+ // The send failed on a socket believed live (perform already marked the
721
+ // island offline). The gesture's default was prevented in dispatch, so
722
+ // for a fallback control honor the contract by hand — the action never
723
+ // left the machine, so the native behavior cannot double-fire.
724
+ if (seq === undefined && "hibikiFallback" in control.dataset) {
725
+ return this.fallthrough(control)
726
+ }
727
+ this.trackControl(seq, control)
605
728
  // Resetting is right for an "add" form and wrong for an edit one: it
606
729
  // runs synchronously, before the server has replied, so a failed commit
607
730
  // would discard what the user typed.
@@ -610,6 +733,19 @@ export default class HibikiController extends ChannelController {
610
733
  }
611
734
  }
612
735
 
736
+ // The native behavior the intercepted event would have had. submit(),
737
+ // not requestSubmit(): the submit event already fired and was prevented,
738
+ // and re-dispatching it would loop straight back through the delegated
739
+ // listener.
740
+ fallthrough(control) {
741
+ if (control instanceof HTMLFormElement) {
742
+ freshenToken(control)
743
+ control.submit()
744
+ } else if (control.href) {
745
+ window.location.assign(control.href)
746
+ }
747
+ }
748
+
613
749
  // One timer per (control, action): two events on one element debounce
614
750
  // independently, and a second control's typing never cancels the first's.
615
751
  debounce(control, action, wait, fire) {
@@ -635,6 +771,21 @@ export default class HibikiController extends ChannelController {
635
771
 
636
772
  export { HibikiController }
637
773
 
774
+ // The public seam without the Stimulus lookup (the header's "App JS
775
+ // reaching the graph"): fire an action through the subscription of the
776
+ // island CONTAINING element. Same return contract as perform — the seq
777
+ // when accepted, undefined when dropped or when no island contains the
778
+ // element (a structural mistake, hence the warn; the offline case stays
779
+ // quiet because it is expected weather).
780
+ export function performOn(element, action, payload = {}) {
781
+ for (let node = element; node; node = node.parentElement) {
782
+ const island = islands.get(node)
783
+ if (island) return island.perform(action, payload)
784
+ }
785
+ console.warn("hibiki: performOn found no island containing", element)
786
+ return undefined
787
+ }
788
+
638
789
  // The Turbo-broadcast race helper the base uses internally, still
639
790
  // exported for custom (non-Stimulus) clients: resolves once Turbo stamps
640
791
  // the `connected` attribute on the given <turbo-cable-stream-source>.
@@ -113,7 +113,15 @@ module Hibiki
113
113
  option_label: "label cursor-pointer justify-start gap-2",
114
114
  option_note: "p-2 opacity-60 italic",
115
115
  checkbox_sm: "checkbox checkbox-sm",
116
- filter_input: "input input-sm w-full mb-2"
116
+ filter_input: "input input-sm w-full mb-2",
117
+
118
+ # The nested fieldset (hibiki:rails:nested): one bordered card per
119
+ # child row, a legend on the fieldset, and the small row controls
120
+ # (↑/↓/Remove/Add).
121
+ fieldset_legend: "fieldset-legend px-2",
122
+ nested_row: "border border-base-300 rounded-box p-3 mb-2 space-y-2",
123
+ btn_ghost_sm: "btn btn-sm btn-ghost",
124
+ btn_outline_sm: "btn btn-sm btn-outline"
117
125
  }.freeze
118
126
 
119
127
  # DaisyUI is a plugin over Tailwind, and the merge says so literally:
@@ -167,7 +175,11 @@ module Hibiki
167
175
  option_label: "flex cursor-pointer items-center gap-2 py-1 text-sm",
168
176
  option_note: "p-2 text-gray-500 italic",
169
177
  checkbox_sm: CHECKBOX,
170
- filter_input: "#{FIELD_FULL} mb-2 text-sm"
178
+ filter_input: "#{FIELD_FULL} mb-2 text-sm",
179
+ fieldset_legend: "px-2 text-sm font-medium text-gray-700",
180
+ nested_row: "rounded-md border border-gray-200 p-3 mb-2 space-y-2",
181
+ btn_ghost_sm: SECONDARY_BUTTON.sub("px-3 py-2", "px-2 py-1"),
182
+ btn_outline_sm: SECONDARY_BUTTON.sub("px-3 py-2", "px-2 py-1")
171
183
  ).freeze
172
184
 
173
185
  # Every lookup misses, so every class argument is omitted entirely.
@@ -26,7 +26,7 @@ module <%= concern_module_name %>
26
26
 
27
27
  <% if search? -%>
28
28
  @<%= target_plural %>_query = Hibiki::State.new("")
29
- # Only read while a row is being edited. The filter narrows this list
29
+ # Only read while a form is open. The filter narrows this list
30
30
  # and nothing else — the selection survives it.
31
31
  @<%= target_plural %>_options = Hibiki::Derived.new do
32
32
  @db_version.value
@@ -40,7 +40,7 @@ module <%= concern_module_name %>
40
40
  { options: rows.first(OPTIONS_LIMIT), truncated: rows.size > OPTIONS_LIMIT }
41
41
  end
42
42
  <% else -%>
43
- # Only read while a row is being edited.
43
+ # Only read while a form is open.
44
44
  @<%= target_plural %>_options = Hibiki::Derived.new do
45
45
  @db_version.value
46
46
  { options: <%= target_class_name %>.order(:<%= label_column %>).pluck(:<%= label_column %>, :id) }
@@ -54,11 +54,19 @@ module <%= concern_module_name %>
54
54
  # Each edit starts with an unfiltered dropdown.
55
55
  @<%= target_plural %>_query.value = ""
56
56
  end
57
+
58
+ # The inline create form's dropdown, likewise. `super if defined?` —
59
+ # on a scaffold without inline create there is nothing to wrap and
60
+ # this hook is inert.
61
+ def new_form(data)
62
+ super if defined?(super)
63
+ @<%= target_plural %>_query.value = ""
64
+ end
57
65
  <% end -%>
58
66
 
59
67
  def list_locals
60
68
  locals = super
61
- return locals unless locals[:editing_id]
69
+ return locals unless locals[:editing_id] || locals[:creating]
62
70
 
63
71
  extras = locals.fetch(:extras, {})
64
72
  locals.merge(extras: extras.merge(<%= extras_key %>: <%= extras_value_source %>))
@@ -66,16 +74,18 @@ module <%= concern_module_name %>
66
74
  end
67
75
 
68
76
  # One checkbox toggled: a SET, not a toggle — the client sends the box's
69
- # checked state, so two tabs converge.
77
+ # checked state, so two tabs converge. `dom` names the form the box
78
+ # belongs to — a row edit and the inline create form can be open at once.
70
79
  def <%= toggle_action %>(data)
71
- return unless @editing_id.peek
80
+ form = multiselect_form(data["dom"].to_s)
81
+ return unless form
72
82
 
73
83
  # Untrusted id — resolve it against the table before it enters the form.
74
84
  id = <%= target_class_name %>.where(id: data["id"]).pick(:id)
75
85
  return unless id
76
86
 
77
- ids = @form.<%= ids_attr %>
78
- @form.<%= ids_attr %> = data["checked"] ? ids | [id] : ids - [id]
87
+ ids = form.<%= ids_attr %>
88
+ form.<%= ids_attr %> = data["checked"] ? ids | [id] : ids - [id]
79
89
  end
80
90
  <% if search? -%>
81
91
 
@@ -84,4 +94,15 @@ module <%= concern_module_name %>
84
94
  @<%= target_plural %>_query.value = data["query"].to_s
85
95
  end
86
96
  <% end -%>
97
+
98
+ private
99
+
100
+ # Which open form a toggle aims at; nil drops the write.
101
+ def multiselect_form(dom)
102
+ if dom.end_with?("_new")
103
+ @new_form if @creating&.peek
104
+ else
105
+ @form if @editing_id.peek
106
+ end
107
+ end
87
108
  end
@@ -173,7 +173,7 @@ module Hibiki
173
173
  [%("checked"), "id", "#{form_ref}.#{ids_attr}.include?(id)",
174
174
  %(id: "\#{#{dom_ref}}_#{target_singular}_\#{id}"),
175
175
  *css_args(:checkbox_sm),
176
- %(**on(:#{toggle_action}, event: :change, with: { id: id }))]
176
+ %(**on(:#{toggle_action}, event: :change, with: { id: id, dom: #{dom_ref} }))]
177
177
  end
178
178
 
179
179
  def filter_field_args
@@ -145,7 +145,11 @@ module Hibiki
145
145
 
146
146
  def inject_component_render
147
147
  path = view_path("row_form.rb")
148
- line = " render #{component_class_name}.new(form: @form, dom: dom, extras: @extras)\n"
148
+ # Two vintages of row_form: dom became a keyword (@dom) when the
149
+ # form was parameterized for inline create; before that it was a
150
+ # private method.
151
+ dom = wired?(path, "@dom = dom") ? "@dom" : "dom"
152
+ line = " render #{component_class_name}.new(form: @form, dom: #{dom}, extras: @extras)\n"
149
153
  return if wired?(path, "Multiselect.new")
150
154
  return manual_wiring(path, line) unless wired?(path, PHLEX_FIELDSET_CLOSE)
151
155
 
@@ -218,7 +222,8 @@ module Hibiki
218
222
  def thread_extras_erb
219
223
  [view_path("_list.html.erb"), view_path("_#{row_partial}.html.erb"),
220
224
  view_path("_#{row_form_partial}.html.erb")].each do |path|
221
- gsub_file path, /^(<%# locals: \(.*?)\) -%>/, '\1, extras: {}) -%>', verbose: false
225
+ # /m: the row form's locals header wraps onto a second line.
226
+ gsub_file path, /^(<%# locals: \(.*?)\) -%>/m, '\1, extras: {}) -%>', verbose: false
222
227
  end
223
228
  # `form: form` appears exactly once per file, inside the one render
224
229
  # call that must pass extras on.
@@ -0,0 +1,30 @@
1
+ Description:
2
+ Wires one parent→child edge of a nested form onto a resource that
3
+ hibiki:rails:scaffold_controller already generated: the child's
4
+ ReactiveForm, a _<child>_fields partial with path-addressed inputs
5
+ (riding the generic Hibiki::Rails::NestedActions channel actions),
6
+ reactive_nested on the parent form, model wiring (has_many +
7
+ accepts_nested_attributes_for allow_destroy), the classic fields_for
8
+ fallback in the full-page form, and the controller's params.expect
9
+ double-array group.
10
+
11
+ Parent and Child models must exist and be migrated, and Child must
12
+ declare belongs_to :<parent>. An optional field list narrows or orders
13
+ the child's form fields; the facts stay the schema's.
14
+
15
+ Depth is composition: run once per edge. A run whose Parent is itself a
16
+ nested child (its _<parent>_fields partial exists) nests into that
17
+ partial instead of the row form.
18
+
19
+ Ordering: a `position` column on the child (or --position=COLUMN) makes
20
+ the fieldset ordered — the has_many gains an order scope, the parent
21
+ form stamps positions from array order, and rows get ↑/↓ buttons. The
22
+ named column is added by migration when missing; --skip-position opts
23
+ out entirely.
24
+
25
+ Examples:
26
+ bin/rails g hibiki:rails:nested Song Credit
27
+
28
+ bin/rails g hibiki:rails:nested Credit Contribution
29
+
30
+ bin/rails g hibiki:rails:nested Song Credit artist:references role:string --position=rank