hibiki_rails 0.7.0 → 0.8.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +61 -0
  3. data/app/assets/javascripts/hibiki.js +99 -8
  4. data/lib/generators/hibiki/rails/multiselect/templates/concern.rb.tt +28 -7
  5. data/lib/generators/hibiki/rails/multiselect_helpers.rb +1 -1
  6. data/lib/generators/hibiki/rails/multiselect_injections.rb +7 -2
  7. data/lib/generators/hibiki/rails/scaffold/scaffold_generator.rb +2 -0
  8. data/lib/generators/hibiki/rails/scaffold_controller/scaffold_controller_generator.rb +3 -0
  9. data/lib/generators/hibiki/rails/scaffold_controller/templates/daisyui/views/_pagination.html.erb.tt +15 -9
  10. data/lib/generators/hibiki/rails/scaffold_controller/templates/none/views/_pagination.html.erb.tt +15 -9
  11. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/daisyui/views/pagination.rb.tt +12 -6
  12. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/none/views/pagination.rb.tt +12 -6
  13. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/controls.rb.tt +30 -6
  14. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/index.rb.tt +18 -3
  15. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/list.rb.tt +29 -4
  16. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/row.rb.tt +19 -5
  17. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/row_form.rb.tt +11 -11
  18. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/tailwind/views/pagination.rb.tt +12 -6
  19. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/channel.rb.tt +96 -12
  20. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/controller.rb.tt +3 -2
  21. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/form.rb.tt +3 -2
  22. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/query.rb.tt +46 -0
  23. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_controls.html.erb.tt +28 -6
  24. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_list.html.erb.tt +24 -2
  25. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_row.html.erb.tt +17 -4
  26. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_row_form.html.erb.tt +8 -7
  27. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/index.html.erb.tt +18 -3
  28. data/lib/generators/hibiki/rails/scaffold_controller/templates/tailwind/views/_pagination.html.erb.tt +15 -9
  29. data/lib/generators/hibiki/rails/scaffold_phlex_helpers.rb +15 -2
  30. data/lib/generators/hibiki/rails/scaffold_shared_views.rb +14 -1
  31. data/lib/generators/hibiki/rails/scaffold_view_helpers.rb +46 -23
  32. data/lib/hibiki/rails/channel.rb +23 -0
  33. data/lib/hibiki/rails/helpers.rb +24 -6
  34. data/lib/hibiki/rails/version.rb +1 -1
  35. metadata +1 -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: 2776ffca09f5d60a467d985550fb12c36570a5a22fd5eb7b0a54ee45c30a68e0
4
+ data.tar.gz: 3a05960da6f7ab9d80a2f6574f6413491610314fd3917a62b48c48127b866830
5
5
  SHA512:
6
- metadata.gz: 5cace159e074313892f3736f5bed6220e1852fc71d85ee5b063af80c31471662f492d945ad57b5589543b54f43d2f4bac3dce3fdf8718fc0b8b1df18d0f5e756
7
- data.tar.gz: 99a518942cc899684e94951d88fea9a08524c83327ae61cc5a924356b16485b33a14cc51fc9fcac3d8dc67d71f96c41ef5f8915d0b97349bc4ed0f74db3de556
6
+ metadata.gz: 29f7fc27b77dbb5e32e94440afc233da170b4095b77bac378426f834a47da38065b5b35881c63dcefe4e3c0053849f0fd2fd23d87d80708aebbec207fe765e93
7
+ data.tar.gz: 5f85d166f5ff9dec3dc23a4d8e4aae9fbece2f53ee63b10a163622e799c77f6b989743f27a935db3c1915b9bb56c96332462b983ccf3fc0cd7ab9ea8f5477fa4
data/CHANGELOG.md CHANGED
@@ -4,6 +4,67 @@ 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.8.0 — 2026-08-15
8
+
9
+ ### Added
10
+
11
+ **`on(..., fallback: true)` — progressive enhancement over native controls.**
12
+ A control's native behavior — a link's href, a form's `action=` — is its
13
+ degraded path. Only a `ready` island intercepts the gesture and performs the
14
+ channel action; while connecting, offline, or stalled the client stands aside
15
+ entirely and the browser does what the markup says. A dead-but-undetected
16
+ socket is caught too: when the subscription reports the send failed, the
17
+ client settles the trip and runs the native behavior by hand
18
+ (`form.submit()` / `location.assign`) — the gesture never left, so it cannot
19
+ double-fire. Two guarantees ride along: `confirm:` still gates the native
20
+ path while scripts run (a destructive submit must not slip past the dialog
21
+ just because the island is down), and before any native submit the client
22
+ freshens the form's `authenticity_token` from the `csrf-token` meta tag —
23
+ forms repainted by a channel are rendered without a session and carry no
24
+ token, so freshening is load-bearing, not an edge case. A truly script-free
25
+ page still holds its first-paint token and needs no help.
26
+
27
+ **`transmit_url` — mirror graph state into the address bar.**
28
+ `transmit_value`'s URL sibling: an equality-gated effect transmits `{ url: }`
29
+ and the client `history.replaceState`s the bar to it. Never `pushState` — the
30
+ URL is a mirror of graph state, not a history entry, so there is no popstate
31
+ choreography and Back leaves the page normally. Same-origin only; the client
32
+ resolves the URL against the page's own origin and refuses anything else.
33
+
34
+ **The scaffold now generates the whole degraded-path pattern.** The query
35
+ object grows a URL half — `from_params` and canonical `url_params` (defaults
36
+ omitted) — shared by the controller's first paint, the island's
37
+ subscribe-params seeding (the graph starts from the URL's state, so its first
38
+ broadcast repaints what the server painted instead of resetting to defaults),
39
+ the pagination hrefs, and the channel's `transmit_url`, which also mirrors an
40
+ open form's URL so a reload mid-edit lands on the standard edit page. The
41
+ controls become one GET form to the index: live, the controls fire channel
42
+ actions; dead, Enter, Apply, and the direction button submit natively and
43
+ `from_params` answers. Edit is a real link to the edit page, Destroy a real
44
+ `button_to` DELETE form, and the page control's links carry real hrefs — all
45
+ with `fallback: true`, in both view layers and every css variant.
46
+
47
+ **The scaffold's list gains an inline create form, on by default.** The New
48
+ link (now inside the island) opens a channel-owned create form at the top of
49
+ the list — its own `ReactiveForm` instance, so an open row edit and an open
50
+ create never fight over state — and degrades to the standard new page. The
51
+ shared row form is parameterized (`dom:`, `save_action:`, `cancel_action:`,
52
+ `field_action:`, `cancel_with:`) and serves both uses; `--skip-create` omits
53
+ the whole surface. Generated forms also gate live validation on `dirty?`, so
54
+ a freshly opened form paints clean instead of flagging every blank field.
55
+
56
+ **`hibiki:rails:multiselect` serves the inline create form too.** The
57
+ dropdown appears with the full option list and an empty selection; each
58
+ checkbox's toggle names the form it belongs to, so an open row edit and the
59
+ create form never cross-write, even side by side.
60
+
61
+ ### Changed
62
+
63
+ **Re-running the scaffold over a pre-0.8.0 app refreshes a same-styled shared
64
+ page control in place** — the regenerated list passes the new `url:` local,
65
+ which the old shared partial does not declare. A page control written under a
66
+ different `--css` style is still kept and noticed, never restyled.
67
+
7
68
  ## 0.7.0 — 2026-08-12
8
69
 
9
70
  ### 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
  //
@@ -122,6 +127,27 @@ const formPayload = (form) => {
122
127
  return payload
123
128
  }
124
129
 
130
+ // Before a fallback form goes native, re-stamp its CSRF token from the
131
+ // page's csrf-token meta — which is first-paint fresh and session-valid.
132
+ // Server-side repaints render without a session (ApplicationController
133
+ // .render has none), so a repainted form embeds a stale token or none at
134
+ // all; and this only ever runs with scripts alive, which is exactly when
135
+ // the DOM may have been repainted. A truly script-free page still holds
136
+ // its first-paint token and never needed the help.
137
+ const freshenToken = (control) => {
138
+ if (!(control instanceof HTMLFormElement)) return
139
+ const meta = document.querySelector('meta[name="csrf-token"]')
140
+ if (!meta) return
141
+ let input = control.querySelector('input[name="authenticity_token"]')
142
+ if (!input) {
143
+ input = document.createElement("input")
144
+ input.type = "hidden"
145
+ input.name = "authenticity_token"
146
+ control.appendChild(input)
147
+ }
148
+ input.value = meta.content
149
+ }
150
+
125
151
  // The subclassable base: one channel subscription per controller element,
126
152
  // identified by a per-page-load cid (data-<identifier>-cid-value).
127
153
  export class ChannelController extends Controller {
@@ -212,13 +238,25 @@ export class ChannelController extends Controller {
212
238
  // ActionCable's own Subscription#perform already writes `action`.)
213
239
  //
214
240
  // Returns the seq so a caller that knows which control fired can attach
215
- // it; nobody has to.
241
+ // it; nobody has to. Returns undefined instead when the socket turned
242
+ // out to be closed under a subscription still believed live.
216
243
  perform(action, payload = {}) {
217
244
  const seq = ++this.seq
218
245
  payload.hbk = seq
219
246
  if (this.subscribed) {
220
247
  this.beginBusy(seq)
221
- this.subscription.perform(action, payload)
248
+ // Action Cable's Subscription#perform returns false when the socket
249
+ // is not open — the gap between the socket dying and the connection
250
+ // monitor noticing, during which `subscribed` still says live. The
251
+ // frame went nowhere: settle rather than letting the trip stall out
252
+ // at the ceiling, and stamp `offline` now instead of when the
253
+ // monitor catches up. The monitor still owns reconnecting; its
254
+ // `connected` callback restores `ready` exactly as after a real gap.
255
+ if (this.subscription.perform(action, payload) === false) {
256
+ this.settle(seq)
257
+ this.linkClosed()
258
+ return undefined
259
+ }
222
260
  return seq
223
261
  }
224
262
  // Queue rather than drop while the subscription is still coming up.
@@ -368,7 +406,7 @@ export class ChannelController extends Controller {
368
406
  this.received(data)
369
407
  }
370
408
 
371
- // Server → DOM (transmit transport). Two message shapes:
409
+ // Server → DOM (transmit transport). Three message shapes:
372
410
  //
373
411
  // { value: { name, text } } — a reactive value (transmit_value): write
374
412
  // the text into every [data-hibiki-value=name] placeholder, document-
@@ -376,11 +414,13 @@ export class ChannelController extends Controller {
376
414
  // textContent assignment keeps values text-only and preserves each
377
415
  // site's own tag/classes, so per-placeholder styling survives updates.
378
416
  //
417
+ // { url } — mirror graph state into the address bar (transmit_url).
418
+ //
379
419
  // { html } — a fragment: swap it in by its root id.
380
420
  //
381
421
  // Anything else is not ours to interpret. Subclasses may override, but
382
422
  // should call super (or handle `value`) to keep reactive values live.
383
- received({ html, value }) {
423
+ received({ html, value, url }) {
384
424
  if (value) {
385
425
  const selector = `[data-hibiki-value="${CSS.escape(value.name)}"]`
386
426
  for (const site of document.querySelectorAll(selector)) {
@@ -388,6 +428,7 @@ export class ChannelController extends Controller {
388
428
  }
389
429
  return
390
430
  }
431
+ if (url !== undefined) return this.replaceUrl(url)
391
432
  if (!html) return
392
433
  const template = document.createElement("template")
393
434
  template.innerHTML = html
@@ -396,6 +437,17 @@ export class ChannelController extends Controller {
396
437
  }
397
438
  }
398
439
 
440
+ // replaceState, never pushState: the URL is a mirror of graph state, not
441
+ // a history entry — Back needs no popstate handling and leaves the page
442
+ // normally. Same-origin only, so a channel can move the bar solely
443
+ // within its own app (replaceState would throw on a cross-origin URL;
444
+ // refusing keeps it silent and intentional).
445
+ replaceUrl(url) {
446
+ const resolved = new URL(url, window.location.href)
447
+ if (resolved.origin !== window.location.origin) return
448
+ history.replaceState(history.state, "", resolved)
449
+ }
450
+
399
451
  // `static channel = "..."` wins; otherwise infer Rails-style from the
400
452
  // identifier: "counter" → CounterChannel, "my-thing" → MyThingChannel.
401
453
  channelName() {
@@ -574,9 +626,27 @@ export default class HibikiController extends ChannelController {
574
626
  if (!token) return
575
627
  const action = token.slice(event.type.length + 2)
576
628
 
577
- // Before the confirm, not after: declining must not let the form
578
- // navigate away.
579
- if (event.type === "submit") event.preventDefault()
629
+ // A fallback control's native behavior IS the degraded path: unless
630
+ // the island is `ready`, stand aside — no perform, no queueing — and
631
+ // the browser follows the href or submits the form to its own
632
+ // action=. Deliberately not the connect-window queue: a queued gesture
633
+ // renders nothing until the link comes up, while the control's
634
+ // destination answers immediately. Two touches before stepping back:
635
+ // a confirm: still gates the native behavior (scripts are running, so
636
+ // a destructive submit must not slip past the dialog), and a form's
637
+ // authenticity_token is freshened — server-rendered repaints carry no
638
+ // session, so their forms embed a stale token or none at all.
639
+ const fallback = "hibikiFallback" in control.dataset
640
+ if (fallback && this.state !== "ready") {
641
+ const message = control.dataset.hibikiConfirm
642
+ if (message && !window.confirm(message)) return event.preventDefault?.()
643
+ return freshenToken(control)
644
+ }
645
+
646
+ // Before the confirm, not after: declining must not let the form (or
647
+ // a fallback control's navigation) proceed. Optional call because the
648
+ // `visible` pseudo-event arrives as a plain object.
649
+ if (event.type === "submit" || fallback) event.preventDefault?.()
580
650
 
581
651
  const message = control.dataset.hibikiConfirm
582
652
  if (message && !window.confirm(message)) return
@@ -601,7 +671,15 @@ export default class HibikiController extends ChannelController {
601
671
  }
602
672
  // perform stamps `hbk` after this merge, so a field literally named hbk
603
673
  // loses to the seq rather than corrupting it.
604
- this.trackControl(this.perform(action, payload), control)
674
+ const seq = this.perform(action, payload)
675
+ // The send failed on a socket believed live (perform already marked the
676
+ // island offline). The gesture's default was prevented in dispatch, so
677
+ // for a fallback control honor the contract by hand — the action never
678
+ // left the machine, so the native behavior cannot double-fire.
679
+ if (seq === undefined && "hibikiFallback" in control.dataset) {
680
+ return this.fallthrough(control)
681
+ }
682
+ this.trackControl(seq, control)
605
683
  // Resetting is right for an "add" form and wrong for an edit one: it
606
684
  // runs synchronously, before the server has replied, so a failed commit
607
685
  // would discard what the user typed.
@@ -610,6 +688,19 @@ export default class HibikiController extends ChannelController {
610
688
  }
611
689
  }
612
690
 
691
+ // The native behavior the intercepted event would have had. submit(),
692
+ // not requestSubmit(): the submit event already fired and was prevented,
693
+ // and re-dispatching it would loop straight back through the delegated
694
+ // listener.
695
+ fallthrough(control) {
696
+ if (control instanceof HTMLFormElement) {
697
+ freshenToken(control)
698
+ control.submit()
699
+ } else if (control.href) {
700
+ window.location.assign(control.href)
701
+ }
702
+ }
703
+
613
704
  // One timer per (control, action): two events on one element debounce
614
705
  // independently, and a second control's typing never cancels the first's.
615
706
  debounce(control, action, wait, fire) {
@@ -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.
@@ -44,6 +44,8 @@ module Hibiki
44
44
  desc: "No windowing at all — skips both pagination modes"
45
45
  class_option :skip_search, type: :boolean, default: false,
46
46
  desc: "Omit the search box and the LIKE terms behind it"
47
+ class_option :skip_create, type: :boolean, default: false,
48
+ desc: "Omit the inline create form — New always navigates"
47
49
  class_option :page_size, type: :numeric, default: 20, desc: "Rows per page"
48
50
  class_option :phlex, type: :boolean, default: false,
49
51
  desc: "Emit Phlex components under app/views instead of ERB templates"
@@ -57,6 +57,8 @@ module Hibiki
57
57
  desc: "No windowing at all — skips both pagination modes"
58
58
  class_option :skip_search, type: :boolean, default: false,
59
59
  desc: "Omit the search box and the LIKE terms behind it"
60
+ class_option :skip_create, type: :boolean, default: false,
61
+ desc: "Omit the inline create form — New always navigates"
60
62
  class_option :page_size, type: :numeric, default: 20,
61
63
  desc: "Rows per page"
62
64
  class_option :skip_routes, type: :boolean,
@@ -297,6 +299,7 @@ module Hibiki
297
299
 
298
300
  def infinite? = options[:infinite_scroll]
299
301
  def searchable? = schema.searchable? && !options[:skip_search]
302
+ def inline_create? = !options[:skip_create]
300
303
 
301
304
  # The view layer. It reaches the templates and nothing else: the graph,
302
305
  # the query object, the form, the actions and the whole data-hibiki
@@ -7,13 +7,19 @@
7
7
  the page-number window) arrives as a local from the list partial. The
8
8
  `--css=` marker below is how a later scaffold run detects a variant
9
9
  mismatch — keep it. -%>
10
- <%%# locals: (page: 1, page_count: 1, pages: [], anchor:, id:) -%>
10
+ <%%# locals: (page: 1, page_count: 1, pages: [], anchor:, id:, url: nil) -%>
11
11
  <%%# Shared page control (--css=<%= css_variant %>), rendered above and below
12
- every scaffolded list. The links are anchors to the list, so a click also
13
- does the browser's own fragment jump; data-turbo="false" keeps Turbo Drive
14
- out. `on` returns a { data: } hash merge, don't splat, or the anchor
15
- stops scrolling. %>
16
- <%% page_data = ->(n) { { turbo: false }.merge(on(:go_to_page, with: { page: n })[:data]) } %>
12
+ every scaffolded list. `url` is an optional ->(page) { path } builder:
13
+ when given, links carry real hrefs (plus the fragment) and fallback —
14
+ a dead island degrades to native navigation. Without it the links are
15
+ plain anchors to the list, as before. data-turbo="false" keeps Turbo
16
+ Drive out. `on` returns a { data: } hash merge, don't splat, or the
17
+ anchor stops scrolling. %>
18
+ <%% href = ->(n) { url ? "#{url.(n)}#{anchor}" : anchor } %>
19
+ <%% page_data = ->(n) do
20
+ { turbo: false }.merge(on(:go_to_page, with: { page: n },
21
+ fallback: url ? true : nil)[:data])
22
+ end %>
17
23
  <%%# The wrapper always renders (so idiomorph has a node to pair with) and
18
24
  stays bare — layout classes here would leave dead space when empty. %>
19
25
  <div id="<%%= id %>">
@@ -24,7 +30,7 @@
24
30
  <%% if page == 1 %>
25
31
  <span class="join-item btn btn-disabled" aria-disabled="true">«</span>
26
32
  <%% else %>
27
- <%%= link_to "«", anchor, class: "join-item btn", rel: "prev",
33
+ <%%= link_to "«", href.(page - 1), class: "join-item btn", rel: "prev",
28
34
  "aria-label": "Previous page", data: page_data.(page - 1) %>
29
35
  <%% end %>
30
36
 
@@ -34,7 +40,7 @@
34
40
  <%% elsif n == page %>
35
41
  <span class="join-item btn btn-active" aria-current="page"><%%= n %></span>
36
42
  <%% else %>
37
- <%%= link_to n, anchor, class: "join-item btn",
43
+ <%%= link_to n, href.(n), class: "join-item btn",
38
44
  "aria-label": "Page #{n}", data: page_data.(n) %>
39
45
  <%% end %>
40
46
  <%% end %>
@@ -42,7 +48,7 @@
42
48
  <%% if page == page_count %>
43
49
  <span class="join-item btn btn-disabled" aria-disabled="true">»</span>
44
50
  <%% else %>
45
- <%%= link_to "»", anchor, class: "join-item btn", rel: "next",
51
+ <%%= link_to "»", href.(page + 1), class: "join-item btn", rel: "next",
46
52
  "aria-label": "Next page", data: page_data.(page + 1) %>
47
53
  <%% end %>
48
54
  </nav>
@@ -10,13 +10,19 @@
10
10
  the page-number window) arrives as a local from the list partial. The
11
11
  `--css=` marker below is how a later scaffold run detects a variant
12
12
  mismatch — keep it. -%>
13
- <%%# locals: (page: 1, page_count: 1, pages: [], anchor:, id:) -%>
13
+ <%%# locals: (page: 1, page_count: 1, pages: [], anchor:, id:, url: nil) -%>
14
14
  <%%# Shared page control (--css=<%= css_variant %>), rendered above and below
15
- every scaffolded list. The links are anchors to the list, so a click also
16
- does the browser's own fragment jump; data-turbo="false" keeps Turbo Drive
17
- out. `on` returns a { data: } hash merge, don't splat, or the anchor
18
- stops scrolling. %>
19
- <%% page_data = ->(n) { { turbo: false }.merge(on(:go_to_page, with: { page: n })[:data]) } %>
15
+ every scaffolded list. `url` is an optional ->(page) { path } builder:
16
+ when given, links carry real hrefs (plus the fragment) and fallback —
17
+ a dead island degrades to native navigation. Without it the links are
18
+ plain anchors to the list, as before. data-turbo="false" keeps Turbo
19
+ Drive out. `on` returns a { data: } hash merge, don't splat, or the
20
+ anchor stops scrolling. %>
21
+ <%% href = ->(n) { url ? "#{url.(n)}#{anchor}" : anchor } %>
22
+ <%% page_data = ->(n) do
23
+ { turbo: false }.merge(on(:go_to_page, with: { page: n },
24
+ fallback: url ? true : nil)[:data])
25
+ end %>
20
26
  <%%# The wrapper always renders, so idiomorph always has a node to pair with. %>
21
27
  <div id="<%%= id %>">
22
28
  <%% if page_count > 1 %>
@@ -26,7 +32,7 @@
26
32
  <%% if page == 1 %>
27
33
  <span aria-disabled="true">«</span>
28
34
  <%% else %>
29
- <%%= link_to "«", anchor, rel: "prev",
35
+ <%%= link_to "«", href.(page - 1), rel: "prev",
30
36
  "aria-label": "Previous page", data: page_data.(page - 1) %>
31
37
  <%% end %>
32
38
 
@@ -36,14 +42,14 @@
36
42
  <%% elsif n == page %>
37
43
  <strong aria-current="page"><%%= n %></strong>
38
44
  <%% else %>
39
- <%%= link_to n, anchor, "aria-label": "Page #{n}", data: page_data.(n) %>
45
+ <%%= link_to n, href.(n), "aria-label": "Page #{n}", data: page_data.(n) %>
40
46
  <%% end %>
41
47
  <%% end %>
42
48
 
43
49
  <%% if page == page_count %>
44
50
  <span aria-disabled="true">»</span>
45
51
  <%% else %>
46
- <%%= link_to "»", anchor, rel: "next",
52
+ <%%= link_to "»", href.(page + 1), rel: "next",
47
53
  "aria-label": "Next page", data: page_data.(page + 1) %>
48
54
  <%% end %>
49
55
  </nav>
@@ -6,13 +6,15 @@
6
6
  # frozen_string_literal: true
7
7
 
8
8
  # Shared page control (--css=<%= css_variant %>), rendered above and below
9
- # every scaffolded list. The links are anchors to the list, so a click also
10
- # does the browser's own fragment jump; data-turbo="false" keeps Turbo Drive
11
- # out.
9
+ # every scaffolded list. `url` is an optional ->(page) { path } builder:
10
+ # when given, links carry real hrefs (plus the fragment) and fallback a
11
+ # dead island degrades to native navigation. Without it the links are plain
12
+ # anchors to the list, as before. data-turbo="false" keeps Turbo Drive out.
12
13
  class Views::Shared::Pagination < Views::Base
13
14
  <%= phlex_includes(:link_to, hibiki: true) %>
14
15
 
15
- <%= initializer_source([%w[page 1], %w[page_count 1], %w[pages []], ["anchor"], ["id"]]) %>
16
+ <%= initializer_source([%w[page 1], %w[page_count 1], %w[pages []], ["anchor"], ["id"],
17
+ %w[url nil]]) %>
16
18
 
17
19
  def view_template
18
20
  # The wrapper always renders (so idiomorph has a node to pair with) and
@@ -53,7 +55,11 @@ class Views::Shared::Pagination < Views::Base
53
55
  # The STRING "false", not the boolean — Phlex omits false-valued attributes.
54
56
  # And merge, don't splat: the anchor needs its own data attribute too.
55
57
  def step(text, n, label:, rel: nil)
56
- link_to text, @anchor, class: "join-item btn", rel:, "aria-label": label,
57
- data: { turbo: "false" }.merge(on(:go_to_page, with: { page: n })[:data])
58
+ link_to text, href(n), class: "join-item btn", rel:, "aria-label": label,
59
+ data: { turbo: "false" }.merge(on(:go_to_page, with: { page: n },
60
+ fallback: @url ? true : nil)[:data])
58
61
  end
62
+
63
+ # A real href when a url builder was given; a bare anchor otherwise.
64
+ def href(n) = @url ? "#{@url.(n)}#{@anchor}" : @anchor
59
65
  end
@@ -6,16 +6,18 @@
6
6
  # frozen_string_literal: true
7
7
 
8
8
  # Shared page control (--css=<%= css_variant %>), rendered above and below
9
- # every scaffolded list. The links are anchors to the list, so a click also
10
- # does the browser's own fragment jump; data-turbo="false" keeps Turbo Drive
11
- # out.
9
+ # every scaffolded list. `url` is an optional ->(page) { path } builder:
10
+ # when given, links carry real hrefs (plus the fragment) and fallback a
11
+ # dead island degrades to native navigation. Without it the links are plain
12
+ # anchors to the list, as before. data-turbo="false" keeps Turbo Drive out.
12
13
  #
13
14
  # Unstyled, so the current page changes ELEMENT (strong) — there is no class
14
15
  # to carry the signal.
15
16
  class Views::Shared::Pagination < Views::Base
16
17
  <%= phlex_includes(:link_to, hibiki: true) %>
17
18
 
18
- <%= initializer_source([%w[page 1], %w[page_count 1], %w[pages []], ["anchor"], ["id"]]) %>
19
+ <%= initializer_source([%w[page 1], %w[page_count 1], %w[pages []], ["anchor"], ["id"],
20
+ %w[url nil]]) %>
19
21
 
20
22
  def view_template
21
23
  # The wrapper always renders, so idiomorph always has a node to pair with.
@@ -55,7 +57,11 @@ class Views::Shared::Pagination < Views::Base
55
57
  # The STRING "false", not the boolean — Phlex omits false-valued attributes.
56
58
  # And merge, don't splat: the anchor needs its own data attribute too.
57
59
  def step(text, n, label:, rel: nil)
58
- link_to text, @anchor, rel:, "aria-label": label,
59
- data: { turbo: "false" }.merge(on(:go_to_page, with: { page: n })[:data])
60
+ link_to text, href(n), rel:, "aria-label": label,
61
+ data: { turbo: "false" }.merge(on(:go_to_page, with: { page: n },
62
+ fallback: @url ? true : nil)[:data])
60
63
  end
64
+
65
+ # A real href when a url builder was given; a bare anchor otherwise.
66
+ def href(n) = @url ? "#{@url.(n)}#{@anchor}" : @anchor
61
67
  end