hibiki_rails 0.4.0 → 0.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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +106 -0
  3. data/README.md +15 -2
  4. data/lib/generators/hibiki/rails/css_variant.rb +7 -4
  5. data/lib/generators/hibiki/rails/scaffold/USAGE +5 -0
  6. data/lib/generators/hibiki/rails/scaffold/scaffold_generator.rb +2 -0
  7. data/lib/generators/hibiki/rails/scaffold_controller/USAGE +5 -0
  8. data/lib/generators/hibiki/rails/scaffold_controller/scaffold_controller_generator.rb +64 -6
  9. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/daisyui/views/pagination.rb.tt +89 -0
  10. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/none/views/pagination.rb.tt +88 -0
  11. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/controls.rb.tt +132 -0
  12. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/edit.rb.tt +24 -0
  13. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/field_error.rb.tt +20 -0
  14. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/form.rb.tt +71 -0
  15. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/index.rb.tt +59 -0
  16. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/list.rb.tt +76 -0
  17. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/new.rb.tt +22 -0
  18. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/row.rb.tt +94 -0
  19. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/row_form.rb.tt +102 -0
  20. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/show.rb.tt +44 -0
  21. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/tailwind/views/pagination.rb.tt +99 -0
  22. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/channel.rb.tt +1 -2
  23. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/controller.rb.tt +12 -0
  24. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/hibiki_busy.css.tt +106 -0
  25. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/member_channel.rb.tt +1 -2
  26. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/index.html.erb.tt +1 -6
  27. data/lib/generators/hibiki/rails/scaffold_phlex_helpers.rb +276 -0
  28. data/lib/generators/hibiki/rails/scaffold_post_install.rb +16 -1
  29. data/lib/generators/hibiki/rails/scaffold_schema.rb +11 -8
  30. data/lib/generators/hibiki/rails/scaffold_transport_stylesheet.rb +141 -0
  31. data/lib/generators/hibiki/rails/scaffold_view_helpers.rb +88 -23
  32. data/lib/hibiki/rails/channel.rb +8 -4
  33. data/lib/hibiki/rails/version.rb +1 -1
  34. metadata +21 -6
  35. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_busy.html.erb.tt +0 -103
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3b21e4afc4b4e74e30f3c32286a1a75b30052e55145d25210bb3cecfbb3bc24c
4
- data.tar.gz: 5c26612918d2b81c3a6a6a134710331ba656b25e9441856627ddf6b3ee093e71
3
+ metadata.gz: 5c47d1cec132b52f2a741ca77596ca90e524b348f385b0db5d2d2992ae0dba0c
4
+ data.tar.gz: 44505ec6d36cb48589f8d924819d3d6ee02b2fb0092b67e0bd0c9b8af316da1b
5
5
  SHA512:
6
- metadata.gz: acf106a0cda97184c5bd9a988bab71efb8be65c76e1f5b08785f96795d7a9dc21f67a5511a9a10ffa7b5fee49d498891ce7516b926eec1868d76820fd7c42c48
7
- data.tar.gz: 5e8d7b8aba5eaf643e5c6f82dcde5c824bd8f9eb1594163dd872bc0a304a6b37a796069385ec8fdc78e8bfe48dd7d9345d4f60a20b77fe3f72760d02e9b0157e
6
+ metadata.gz: 85985021ba53ce7e11eb4b6713905e8abee6c9e0c6babd36a1fd8debb06887994827cce847ae82a20df822d53ff14398be3f4515f416354ad1058f7562151037
7
+ data.tar.gz: 031d605405895afed00c8119eabf62e7e545213a059f2d7e424f78fc08623939e550231168d829131f494d7bec3e9fa352f151cf41514700d1c8a90e3a68f6a0
data/CHANGELOG.md CHANGED
@@ -4,6 +4,112 @@ 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.5.0 — 2026-08-05
8
+
9
+ ### Added
10
+
11
+ **`--phlex` on both scaffold generators.** `bin/rails g hibiki:rails:scaffold
12
+ Book title:string --phlex` emits Phlex components under `app/views/books/*.rb`,
13
+ namespaced `Views::`, instead of ERB templates. Purely additive: without the
14
+ flag nothing about the generated output changes, byte for byte.
15
+
16
+ Only the view layer moves. The channels, the query object, the ReactiveForm,
17
+ the model injections, every action and the whole `data-hibiki-*` protocol are
18
+ the same either way — the controller gains an explicit `render Views::…` at
19
+ each of its six render sites, and the two `broadcast_morph` calls swap
20
+ `partial:`/`locals:` for `renderable:`, and that is the entire difference
21
+ outside the templates.
22
+
23
+ Needs `phlex-rails` and `bin/rails g phlex:install`. The generator warns when
24
+ either is missing and writes the files anyway, so the scaffold can come first.
25
+
26
+ Four things worth knowing, because they are not what an ERB reader expects.
27
+ Phlex renders `String`, `Symbol`, `Integer` and `Float` and raises on anything
28
+ else, so date, time and decimal columns are emitted with an explicit `to_s`.
29
+ Phlex omits a `false`-valued attribute entirely, so the page control's
30
+ `data-turbo` is the string `"false"`. Phlex emits no whitespace between
31
+ siblings, so the components space their inline neighbours explicitly. And
32
+ `options_for_select` outputs directly and raises if its return value is passed
33
+ on, so both select sites take their options from a block.
34
+
35
+ The page control is still the one component with a per-`--css` fork, in both
36
+ trees. Under Phlex the plain-Tailwind fork's *reason* dissolves — a hoisted
37
+ template local becomes an ordinary constant — but it stays forked so the two
38
+ trees match file for file and a future `--css` decision stays a diff rather
39
+ than a judgement call.
40
+
41
+ ### Changed
42
+
43
+ **The loading and connection recipes are an asset, not a partial.** They were a
44
+ 103-line inline `<style>` emitted as `app/views/<resource>/_busy.html.erb` and
45
+ rendered once per page; they are now
46
+ `app/assets/stylesheets/hibiki_busy.css`, written once per app.
47
+
48
+ Per resource was always wrong: every rule keys on an attribute the client
49
+ stamps and none of them mentions a model, so a two-resource app carried two
50
+ byte-identical copies. It also put CSS somewhere a Content-Security-Policy that
51
+ forbids inline styles would reject.
52
+
53
+ The generator wires it for you: a cssbundling or tailwindcss-rails entry
54
+ stylesheet gets an `@import`, a layout already using
55
+ `stylesheet_link_tag :app` or `:all` needs nothing, and anything else gets a
56
+ `stylesheet_link_tag` injected into the layout. Only when none of those applies
57
+ does it print the line to add. Every branch is idempotent.
58
+
59
+ **If you re-run the generator on an app scaffolded before this**, the old
60
+ `_busy.html.erb` stays on disk — a generator never deletes — and nothing
61
+ renders it any more. The post-install output names it; delete it.
62
+
63
+ The rules are deliberately unlayered, and the file says so: two of them set
64
+ `display` on elements that also carry Tailwind utilities, and unlayered
65
+ declarations beat `@layer utilities` whatever the link order.
66
+
67
+ ### Fixed
68
+
69
+ **The npm package no longer drags in a second copy of `@rails/actioncable`.**
70
+ It moves from `dependencies` to `peerDependencies` at `>= 7.0`, matching
71
+ turbo-rails' own range.
72
+
73
+ Why there were two: `@rails/actioncable`'s npm `latest` dist-tag is 7.2.302 even
74
+ though 8.x is published, and resolvers prefer `latest` when it satisfies the
75
+ range. So turbo-rails' `>=7.0` took 7.2.302 while this package's `>= 8.0` was
76
+ forced up to 8.1.301, and both ended up in the bundle — about 16 KB of duplicate
77
+ client, and two separate module instances that could never share a consumer.
78
+ A fresh install happened to hoist a single copy; the duplicate appeared when
79
+ adding hibiki-rails to an app whose lockfile already pinned 7.2.302, which is
80
+ every existing app.
81
+
82
+ If your bundler warns about an unmet peer, install `@rails/actioncable`
83
+ explicitly — but a stock Rails app already has it via turbo-rails, and both bun
84
+ and npm 7+ auto-install a missing peer. The client uses only `createConsumer`,
85
+ `subscriptions.create` and `subscription.perform`, all stable since Action
86
+ Cable 6, so the lower floor changes nothing at runtime.
87
+
88
+ ### Changed — BREAKING
89
+
90
+ **The Rails floor is now 8.0.** `actioncable` and `railties` move from
91
+ `>= 7.1` to `>= 8.0`, and the 7.1 / 7.2 CI legs are gone. Ruby stays at `>= 3.4`.
92
+
93
+ This is a correction as much as a policy change: **generated controllers never
94
+ ran on Rails 7.** `hibiki:rails:scaffold` emits `params.expect` at two sites,
95
+ inherited from Rails 8's own scaffold, and `ActionController::Parameters#expect`
96
+ does not exist before 8.0 — so a generated controller raised `NoMethodError` on
97
+ the first request to `show`, `edit`, `update`, `create` or `destroy` on 7.1 and
98
+ 7.2. The generator suite never caught it because those specs assert on emitted
99
+ source text and never boot the result.
100
+
101
+ The alternative — branching the template on `Rails::VERSION` — was rejected: it
102
+ would also need a way to *execute* generated output on the old legs, which is
103
+ more work than dropping two CI legs for a version combination (Rails 7.x on
104
+ Ruby >= 3.4) that barely exists.
105
+
106
+ **If you are on Rails 7.1 or 7.2, stay on 0.4.0.** It remains available and is
107
+ unaffected; nothing in 0.5.0 is a security fix for it. Note that the *runtime*
108
+ half of the gem — channels, the graph, the broadcast helpers, the client — has
109
+ no known 8.0-only dependency; it is the generators whose output does. The floor
110
+ applies to the whole gem anyway, because shipping a gem whose headline generator
111
+ cannot run on its own declared floor is what got us here.
112
+
7
113
  ## 0.4.0 — 2026-08-03
8
114
 
9
115
  ### Added
data/README.md CHANGED
@@ -10,7 +10,7 @@ Turbo Streams broadcast → Turbo morphs the DOM
10
10
 
11
11
  A graph lives per cable connection (in practice: per browser tab), built when the channel subscribes and disposed when it unsubscribes. Effects subscribe to whatever signals they read; when an action writes a signal, exactly the affected effects re-render and broadcast.
12
12
 
13
- Supports Rails >= 7.1, Ruby >= 3.4.
13
+ Supports Rails >= 8.0, Ruby >= 3.4.
14
14
 
15
15
  ## Rails quick start
16
16
 
@@ -80,6 +80,13 @@ match your app (DaisyUI, Tailwind, or unstyled — detected automatically, or
80
80
  forced with `--css=`). Run `bin/rails g hibiki:rails:scaffold --help` for the
81
81
  rest of the options.
82
82
 
83
+ Pass `--phlex` for Phlex components under `app/views/books/*.rb` instead of ERB
84
+ templates. It needs `phlex-rails` and `bin/rails g phlex:install`, and it
85
+ changes the view layer only — the channels, the query object, the form and the
86
+ whole client protocol are the same either way. Note this is a different thing
87
+ from the `hibiki_phlex` gem, which makes a component own reactive state; here
88
+ the channel owns the state and the components are ordinary stateless views.
89
+
83
90
  Listing the fields yourself only chooses their order and which ones appear — the
84
91
  model still answers everything else, so the live validation, a number field's
85
92
  `min:`/`max:` and a `belongs_to`'s display label all survive the choice.
@@ -90,6 +97,12 @@ each model a `belongs_to` points at gains the `has_many` half plus a ping of its
90
97
  own, so renaming a parent repaints the lists that print its name. Both are
91
98
  idempotent, announced, and leave anything you already declared alone.
92
99
 
100
+ One file lands outside the resource: `app/assets/stylesheets/hibiki_busy.css`,
101
+ which styles the loading and connection state the client stamps. It is written
102
+ once per app, shared by every generated resource, and wired into your stylesheet
103
+ or layout automatically — the post-install output says which, or gives you the
104
+ line to add when it cannot tell.
105
+
93
106
  Restart the server afterwards: `app/forms/` is new, and Rails works out its
94
107
  autoload paths at boot.
95
108
 
@@ -107,7 +120,7 @@ Congratulations! Now you have your first reactive component!
107
120
 
108
121
  Documentation site: <https://planetaska.github.io/hibiki/rails-introduction/>
109
122
 
110
- Release notes and upgrade advice: [CHANGELOG.md](CHANGELOG.md). **If you run Rails 7.1 or 7.2, read the 0.3.0 entry** — it fixes channel lifecycle methods that were client-invocable on those versions.
123
+ Release notes and upgrade advice: [CHANGELOG.md](CHANGELOG.md). **Rails 7.1 and 7.2 are supported up to 0.4.0 only** — 0.5.0 raises the floor to 8.0. If you are staying on 0.4.0 with Rails 7.x, read the 0.3.0 entry: it fixes channel lifecycle methods that were client-invocable on those versions.
111
124
 
112
125
  ## Development
113
126
 
@@ -121,10 +121,13 @@ module Hibiki
121
121
  badge_warning: "inline-flex items-center rounded-full bg-amber-100 px-2 py-1 " \
122
122
  "text-xs font-medium text-amber-800",
123
123
  # `inline-block` is load-bearing, not decoration: a bare span is
124
- # inline, where h-3/w-3 do nothing. It is safe against the
125
- # visibility rules because this stylesheet is linked in <head> and
126
- # the transport <style> is in the body, so equal specificity goes
127
- # hibiki's way.
124
+ # inline, where h-3/w-3 do nothing. It also collides with
125
+ # .hbk-control-busy's `display: none` at equal specificity — the
126
+ # transport stylesheet wins because it is UNLAYERED and Tailwind's
127
+ # utilities are in @layer utilities, which beats them whatever the
128
+ # link order. (Before 0.5.0 the rules were a <style> in the body and
129
+ # won on document order instead; that is why the stylesheet's header
130
+ # now says not to wrap it in a layer.)
128
131
  spinner: "inline-block h-3 w-3 animate-spin rounded-full border-2 " \
129
132
  "border-current border-r-transparent align-[-0.15em]",
130
133
  warning_text: "text-amber-600",
@@ -25,8 +25,13 @@ Example:
25
25
  app/forms/book_form.rb
26
26
  app/controllers/books_controller.rb
27
27
  app/views/books/*
28
+ app/assets/stylesheets/hibiki_busy.css
28
29
  And add to config/routes.rb:
29
30
  resources :books
30
31
 
31
32
  Restart the server afterwards: Rails computes autoload paths from the
32
33
  app/* glob at boot, and app/forms is new.
34
+
35
+ Pass --phlex to emit Phlex components under app/views/books/*.rb instead of
36
+ ERB templates. The view layer is the only thing it changes; it needs the
37
+ phlex-rails gem and `bin/rails g phlex:install`.
@@ -45,6 +45,8 @@ module Hibiki
45
45
  class_option :skip_search, type: :boolean, default: false,
46
46
  desc: "Omit the search box and the LIKE terms behind it"
47
47
  class_option :page_size, type: :numeric, default: 20, desc: "Rows per page"
48
+ class_option :phlex, type: :boolean, default: false,
49
+ desc: "Emit Phlex components under app/views instead of ERB templates"
48
50
 
49
51
  def initialize(...)
50
52
  super
@@ -28,7 +28,12 @@ Example:
28
28
  app/forms/book_form.rb
29
29
  app/controllers/books_controller.rb
30
30
  app/views/books/*
31
+ app/assets/stylesheets/hibiki_busy.css
31
32
  And modify:
32
33
  app/models/book.rb
33
34
  app/models/author.rb # each model a belongs_to points at
34
35
  config/routes.rb
36
+
37
+ Pass --phlex to emit Phlex components under app/views/books/*.rb instead of
38
+ ERB templates. The view layer is the only thing it changes; it needs the
39
+ phlex-rails gem and `bin/rails g phlex:install`.
@@ -8,7 +8,9 @@ require_relative "../scaffold_view_helpers"
8
8
  require_relative "../scaffold_model_injection"
9
9
  require_relative "../scaffold_parent_injection"
10
10
  require_relative "../scaffold_parent_notices"
11
+ require_relative "../scaffold_phlex_helpers"
11
12
  require_relative "../scaffold_post_install"
13
+ require_relative "../scaffold_transport_stylesheet"
12
14
  require_relative "../scaffold_schema"
13
15
  require_relative "../css_variant"
14
16
 
@@ -29,10 +31,12 @@ module Hibiki
29
31
  include GeneratorHelpers
30
32
  include ScaffoldHelpers
31
33
  include ScaffoldViewHelpers
34
+ include ScaffoldPhlexHelpers
32
35
  include ScaffoldModelInjection
33
36
  include ScaffoldParentInjection
34
37
  include ScaffoldParentNotices
35
38
  include ScaffoldPostInstall
39
+ include ScaffoldTransportStylesheet
36
40
 
37
41
  TEMPLATE_ROOT = File.expand_path("templates", __dir__)
38
42
 
@@ -55,6 +59,8 @@ module Hibiki
55
59
  desc: "Rows per page"
56
60
  class_option :skip_routes, type: :boolean,
57
61
  desc: "Don't add routes to config/routes.rb"
62
+ class_option :phlex, type: :boolean, default: false,
63
+ desc: "Emit Phlex components under app/views instead of ERB templates"
58
64
 
59
65
  # Deliberately NO check_class_collision on the controller. Rails' own
60
66
  # scaffold_controller assumes a brand-new resource; this one's primary
@@ -62,6 +68,13 @@ module Hibiki
62
68
  # controller existing is the normal case rather than an error. Thor's
63
69
  # per-file conflict prompt is the right granularity for that.
64
70
 
71
+ # First, so a --phlex app missing its wiring reads that at the TOP of
72
+ # the output rather than under forty created-file lines. It only warns,
73
+ # so the files are written either way — see ScaffoldPhlexHelpers.
74
+ def check_phlex_wiring
75
+ warn_without_phlex_rails
76
+ end
77
+
65
78
  # Resolving the schema first means a missing model or an unmigrated
66
79
  # table aborts before anything is written, rather than half-way through.
67
80
  def resolve_schema
@@ -72,6 +85,14 @@ module Hibiki
72
85
  @new_app_dirs = %w[app/forms app/channels app/models app/views].reject { exists?(it) }
73
86
  end
74
87
 
88
+ # One file per app, not per resource, and the only thing this generator
89
+ # writes outside app/{channels,models,forms,views,controllers}. It
90
+ # carries no model knowledge at all — the rules key on the client's
91
+ # attributes — so a second resource finds it already there.
92
+ def create_stylesheet
93
+ create_transport_stylesheet
94
+ end
95
+
75
96
  def create_channels
76
97
  template "channel.rb.tt", collection_channel_path
77
98
  template "member_channel.rb.tt", member_channel_path
@@ -91,7 +112,9 @@ module Hibiki
91
112
  end
92
113
 
93
114
  def create_views
94
- %w[index show new edit _form _list _controls _field_error _busy].each do |view|
115
+ return create_phlex_views if phlex?
116
+
117
+ %w[index show new edit _form _list _controls _field_error].each do |view|
95
118
  template "views/#{view}.html.erb.tt", view_path("#{view}.html.erb")
96
119
  end
97
120
 
@@ -149,18 +172,48 @@ module Hibiki
149
172
 
150
173
  private
151
174
 
175
+ # No underscore, no .html.erb, and no _row/_row_form rename: all three
176
+ # are Rails PARTIAL conventions, and a Phlex component is reached by
177
+ # constant. Dropping the rename also drops a step — the file is
178
+ # row.rb because Zeitwerk wants basename.camelize, and calling it
179
+ # book.rb would put Views::Books::Book one reformat away from shadowing
180
+ # the model inside its own namespace.
181
+ #
182
+ # Private, because a public method on a Thor generator is a command.
183
+ def create_phlex_views
184
+ %w[index show new edit form list row row_form controls field_error].each do |view|
185
+ template "views/#{view}.rb.tt", view_path("#{view}.rb")
186
+ end
187
+
188
+ template "views/pagination.rb.tt", view_path("pagination.rb") unless infinite?
189
+ end
190
+
152
191
  # Ordered so an app's own lib/templates/... override wins first, then
153
- # the per-variant fork, then the one shared template set. Only
154
- # _pagination has a per-variant file — a tag-name change under `none`
155
- # and a shared-local extraction under `tailwind`, neither of which a
156
- # class-token map can express — so everything else falls through to
157
- # shared/ and there is exactly one template set to maintain.
192
+ # the view layer, then the per-variant fork, then the one shared
193
+ # template set. Only the page control has a per-variant file — a
194
+ # tag-name change under `none` and a shared-local extraction under
195
+ # `tailwind`, neither of which a class-token map can express — so
196
+ # everything else falls through to shared/.
197
+ #
198
+ # The two layers' templates never collide (list.rb.tt vs
199
+ # _list.html.erb.tt), so the phlex roots could have lived beside the
200
+ # ERB ones. They are a subtree instead because `ls` is where this
201
+ # phase's permanent cost should be visible: every view change is now
202
+ # two templates, forever.
158
203
  def source_paths
159
204
  @source_paths ||= [*self.class.source_paths_for_search,
205
+ *phlex_source_paths,
160
206
  File.join(TEMPLATE_ROOT, css_variant.to_s),
161
207
  File.join(TEMPLATE_ROOT, "shared")]
162
208
  end
163
209
 
210
+ def phlex_source_paths
211
+ return [] unless phlex?
212
+
213
+ [File.join(TEMPLATE_ROOT, "phlex", css_variant.to_s),
214
+ File.join(TEMPLATE_ROOT, "phlex", "shared")]
215
+ end
216
+
164
217
  # Explicit attributes always win, matching Rails' own precedence; the
165
218
  # schema decides the column list only when the argument list is empty.
166
219
  # That order also happens to be the only workable one, because
@@ -246,6 +299,11 @@ module Hibiki
246
299
 
247
300
  def infinite? = options[:infinite_scroll]
248
301
  def searchable? = schema.searchable? && !options[:skip_search]
302
+
303
+ # The view layer. It reaches the templates and nothing else: the graph,
304
+ # the query object, the form, the actions and the whole data-hibiki
305
+ # protocol are the same either way.
306
+ def phlex? = options[:phlex]
249
307
  end
250
308
  end
251
309
  end
@@ -0,0 +1,89 @@
1
+ <%#- ESCAPING: none — plain Ruby out, every <% is generation time. -%>
2
+ # frozen_string_literal: true
3
+
4
+ # The numbered page control. It lives INSIDE the replaced fragment, like every
5
+ # other control in there: page_count is a server number, so a single-page list
6
+ # simply stops rendering this element. Placed outside the fragment it would
7
+ # never be re-rendered and could only ever change its TEXT (reactive values are
8
+ # textContent-only), leaving a control whose links point at pages that no
9
+ # longer exist.
10
+ #
11
+ # SCROLL. The links are anchors to the list, not buttons, and that IS the
12
+ # scroll answer — the client only preventDefaults `submit`, so the click both
13
+ # performs the action and does the browser's own fragment jump, which puts the
14
+ # top of the list back in view exactly as a full page load would. No client
15
+ # capability needed. data-turbo="false" keeps Turbo Drive out of it: a
16
+ # same-page hash with Turbo off is a plain scroll, not a navigation.
17
+ #
18
+ # THE STRING "false", not the boolean. Phlex OMITS a false-valued attribute
19
+ # entirely, so `turbo: false` would emit no data-turbo at all and Turbo Drive
20
+ # would take the click back. The ERB twin's boolean is right there and wrong
21
+ # here.
22
+ #
23
+ # No generation token — a page number is idempotent, so a double fire is one
24
+ # write and one render.
25
+ #
26
+ # PENDING. Nothing here asks for it: the client stamps data-hibiki-busy on the
27
+ # control that fired, so the clicked page link dims itself while the trip is
28
+ # out, and the swap that answers it destroys the attribute along with the link.
29
+ # The bar above the list is the other half — the anchor jump has already put it
30
+ # in view by then.
31
+ class <%= view_class_name(:pagination) %> < Views::Base
32
+ <%= phlex_includes(:link_to, hibiki: true) %>
33
+
34
+ <%= initializer_source([%w[page 1], %w[page_count 1]]) %>
35
+
36
+ def view_template
37
+ # The id-carrying wrapper stays put and stays BARE. It always renders, so
38
+ # idiomorph always has a node to pair with, and it holds no layout classes
39
+ # of its own — with no page control it is empty, and padding here would
40
+ # leave dead vertical space on a list that has none.
41
+ div(id: "<%= pagination_dom_id %>") do
42
+ nav<%= arg_list(*css_args(:pagination_nav), %(aria_label: "Pagination")) %> { items } if @page_count > 1
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def items
49
+ # Prev/next stay rendered and go inert at the ends rather than
50
+ # disappearing, so the control keeps its width across a page change — one
51
+ # that reflows under the pointer is how you mis-click a list that just
52
+ # repainted. A span, not a disabled link: there is no page to name, so
53
+ # there is no action to stamp.
54
+ @page == 1 ? inert("«") : step("«", @page - 1, rel: "prev", label: "Previous page")
55
+ whitespace
56
+
57
+ <%= query_class_name %>.page_numbers(page: @page, page_count: @page_count).each do |n|
58
+ if n.nil?
59
+ span(class: "join-item btn btn-disabled", aria_hidden: "true") { "…" }
60
+ elsif n == @page
61
+ # aria-current is the only thing that tells a screen reader which page
62
+ # it is on: the visual cue is a class, and the link text is just a
63
+ # number either way.
64
+ span(class: "join-item btn btn-active", aria_current: "page") { n.to_s }
65
+ else
66
+ step(n.to_s, n, label: "Page #{n}")
67
+ end
68
+
69
+ # Phlex emits nothing between siblings; without this the page numbers run
70
+ # together, which under --css=none is all there is to separate them.
71
+ whitespace
72
+ end
73
+
74
+ @page == @page_count ? inert("»") : step("»", @page + 1, rel: "next", label: "Next page")
75
+ end
76
+
77
+ def inert(glyph)
78
+ span(class: "join-item btn btn-disabled", aria_disabled: "true") { glyph }
79
+ end
80
+
81
+ # `on` returns a { data: } hash, so an element needing its OWN data
82
+ # attributes has to MERGE rather than splat: a bare ** after
83
+ # `data: { turbo: "false" }` would silently replace it and the anchor would
84
+ # stop scrolling.
85
+ def step(text, n, label:, rel: nil)
86
+ link_to text, "#<%= list_dom_id %>", class: "join-item btn", rel:, "aria-label": label,
87
+ data: { turbo: "false" }.merge(on(:go_to_page, with: { page: n })[:data])
88
+ end
89
+ end
@@ -0,0 +1,88 @@
1
+ <%#- ESCAPING: none — plain Ruby out, every <% is generation time. -%>
2
+ # frozen_string_literal: true
3
+
4
+ # The numbered page control. It lives INSIDE the replaced fragment, like every
5
+ # other control in there: page_count is a server number, so a single-page list
6
+ # simply stops rendering this element. Placed outside the fragment it would
7
+ # never be re-rendered and could only ever change its TEXT (reactive values are
8
+ # textContent-only), leaving a control whose links point at pages that no
9
+ # longer exist.
10
+ #
11
+ # SCROLL. The links are anchors to the list, not buttons, and that IS the
12
+ # scroll answer — the client only preventDefaults `submit`, so the click both
13
+ # performs the action and does the browser's own fragment jump, which puts the
14
+ # top of the list back in view exactly as a full page load would. No client
15
+ # capability needed. data-turbo="false" keeps Turbo Drive out of it: a
16
+ # same-page hash with Turbo off is a plain scroll, not a navigation.
17
+ #
18
+ # THE STRING "false", not the boolean. Phlex OMITS a false-valued attribute
19
+ # entirely, so `turbo: false` would emit no data-turbo at all and Turbo Drive
20
+ # would take the click back. The ERB twin's boolean is right there and wrong
21
+ # here.
22
+ #
23
+ # No generation token — a page number is idempotent, so a double fire is one
24
+ # write and one render.
25
+ #
26
+ # PENDING. Nothing here asks for it: the client stamps data-hibiki-busy on the
27
+ # control that fired, so the clicked page link dims itself while the trip is
28
+ # out, and the swap that answers it destroys the attribute along with the link.
29
+ # The bar above the list is the other half — the anchor jump has already put it
30
+ # in view by then.
31
+ #
32
+ # The sharpest of the three forks, and the one Phlex does NOT dissolve. Both
33
+ # styled variants mark the current page with a CLASS. With no stylesheet there
34
+ # is nothing to carry that signal, so the current page has to change ELEMENT —
35
+ # strong rather than a styled span — and the inert prev/next markers become
36
+ # plain text. aria-current still does the work for a screen reader either way;
37
+ # what changes is that sighted users need the element.
38
+ class <%= view_class_name(:pagination) %> < Views::Base
39
+ <%= phlex_includes(:link_to, hibiki: true) %>
40
+
41
+ <%= initializer_source([%w[page 1], %w[page_count 1]]) %>
42
+
43
+ def view_template
44
+ # The id-carrying wrapper always renders, so idiomorph always has a node to
45
+ # pair with.
46
+ div(id: "<%= pagination_dom_id %>") do
47
+ nav(aria_label: "Pagination") { items } if @page_count > 1
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def items
54
+ # Prev/next stay rendered and go inert at the ends rather than
55
+ # disappearing, so the control keeps its position across a page change. A
56
+ # span, not a disabled link: there is no page to name, so there is no
57
+ # action to stamp.
58
+ @page == 1 ? inert("«") : step("«", @page - 1, rel: "prev", label: "Previous page")
59
+ whitespace
60
+
61
+ <%= query_class_name %>.page_numbers(page: @page, page_count: @page_count).each do |n|
62
+ if n.nil?
63
+ span(aria_hidden: "true") { "…" }
64
+ elsif n == @page
65
+ strong(aria_current: "page") { n.to_s }
66
+ else
67
+ step(n.to_s, n, label: "Page #{n}")
68
+ end
69
+
70
+ # Phlex emits nothing between siblings; without this the page numbers run
71
+ # together, which under --css=none is all there is to separate them.
72
+ whitespace
73
+ end
74
+
75
+ @page == @page_count ? inert("»") : step("»", @page + 1, rel: "next", label: "Next page")
76
+ end
77
+
78
+ def inert(glyph)
79
+ span(aria_disabled: "true") { glyph }
80
+ end
81
+
82
+ # `on` returns a { data: } hash, so an element needing its OWN data
83
+ # attributes has to MERGE rather than splat, or the anchor stops scrolling.
84
+ def step(text, n, label:, rel: nil)
85
+ link_to text, "#<%= list_dom_id %>", rel:, "aria-label": label,
86
+ data: { turbo: "false" }.merge(on(:go_to_page, with: { page: n })[:data])
87
+ end
88
+ end