hibiki_rails 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +79 -0
- data/app/assets/javascripts/hibiki-motion.js +160 -0
- data/app/assets/javascripts/hibiki.js +57 -11
- data/config/importmap.rb +2 -0
- data/lib/generators/hibiki/rails/css_variant.rb +6 -0
- data/lib/generators/hibiki/rails/scaffold/USAGE +7 -0
- data/lib/generators/hibiki/rails/scaffold/scaffold_generator.rb +2 -0
- data/lib/generators/hibiki/rails/scaffold_controller/USAGE +6 -0
- data/lib/generators/hibiki/rails/scaffold_controller/scaffold_controller_generator.rb +17 -4
- data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/list.rb.tt +20 -9
- data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/row.rb.tt +29 -0
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/hibiki_motion.css.tt +70 -0
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_list.html.erb.tt +24 -14
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_row.html.erb.tt +26 -0
- data/lib/generators/hibiki/rails/scaffold_motion.rb +49 -0
- data/lib/generators/hibiki/rails/scaffold_post_install.rb +1 -0
- data/lib/generators/hibiki/rails/scaffold_transport_stylesheet.rb +106 -53
- data/lib/hibiki/rails/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ce497d3d19ee46dede8bb9973f3a408e8b724f588ee2ebce3a4c0a0e5aa998f1
|
|
4
|
+
data.tar.gz: 1eea58578f6a73f3617a8f717cc1407023d741c1bbd1fb68f534b1fe135a4caa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fd67705cbe7db3a1eb1ddcd5dd914e7f8377f01070d564f3dca40a96f205833ef0a442f3a74bc68a8928d6577461a8bc83427c0e0117c711d3fd5733fa478b9e
|
|
7
|
+
data.tar.gz: eefeee042142381f1e2f1f2dba0d49d2884db32409a029d08215a9479aefce564bc7e03333c23b525ade2dc37fc3111a89ebbabd52fed6adf450bfe19c9f63bf
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,85 @@ 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.15.0 — 2026-09-09
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
**Motion for generated pages, and the `hibiki-rails/motion` module.** A
|
|
12
|
+
re-rendered fragment is merged into the page in place, so a removed element
|
|
13
|
+
was gone before the browser could draw it leaving. The new opt-in module
|
|
14
|
+
holds a render while a marked element's transitions finish: mark an element
|
|
15
|
+
`data-motion` (it needs an id), give it a transition, and `import
|
|
16
|
+
"hibiki-rails/motion"` once beside your controllers. The module writes
|
|
17
|
+
`data-motion-leaving` on each marked element the incoming fragment lacks,
|
|
18
|
+
waits for its transitions, then lets the render run; it brackets an inserted
|
|
19
|
+
marked element with `data-motion-entering` so a clip can be scoped to the
|
|
20
|
+
motion. `data-motion="own"` animates the leave only when the gesture that
|
|
21
|
+
removed the element came from inside it, read from the island's own trip
|
|
22
|
+
records. Renders queue in arrival order; a hidden tab waits for nothing;
|
|
23
|
+
every wait races a one-second ceiling. Both transports hold. This is a
|
|
24
|
+
client change: the four `data-motion*` attributes are public, unlike the
|
|
25
|
+
private `data-hibiki-*` ones. Docs: [Motion].
|
|
26
|
+
|
|
27
|
+
- The scaffold emits it by default under `--css=daisyui` and `tailwind`:
|
|
28
|
+
the inline create card and the row's edit form slide open and shut
|
|
29
|
+
(`hbk-slide` / `hbk-slide-body`), and a row you destroy slides out to the
|
|
30
|
+
left before the list closes the gap (`hbk-slide-x` / `hbk-slide-x-body`,
|
|
31
|
+
marked `own`). The row's display markup gains an id-bearing `contents`
|
|
32
|
+
wrapper, because the merge pairs an anonymous `div` with the edit wrapper
|
|
33
|
+
and rewrites it in place, and `@starting-style` fires only on a real
|
|
34
|
+
insertion; under motion the row's card and card-body are one element, so
|
|
35
|
+
the add-on generators' anchors still hold.
|
|
36
|
+
- `app/assets/stylesheets/hibiki_motion.css`, once per app beside the busy
|
|
37
|
+
sheet and wired the same four ways: slide, slide-x, fade, fly, scale and
|
|
38
|
+
blur as Tailwind `@utility` rules (the utilities layer sits above daisyUI's
|
|
39
|
+
components, and a single utility on the element still wins), with duration
|
|
40
|
+
and easing tuned there. Needs Tailwind 4.1. The generator appends
|
|
41
|
+
`import "hibiki-rails/motion"` to `app/javascript/application.js` once
|
|
42
|
+
and prints a `motion` notice when it cannot.
|
|
43
|
+
- `--skip-motion` on both scaffold commands leaves every trace out.
|
|
44
|
+
`--css=none` never emits it: the transitions are Tailwind utilities, and
|
|
45
|
+
its output is byte-identical to a `--skip-motion` run.
|
|
46
|
+
- Packaging: the engine pins `hibiki-rails/motion`; the npm package exports
|
|
47
|
+
`./motion`. The module imports `"hibiki-rails"` by its bare name, which
|
|
48
|
+
the pin and the package self-reference both resolve.
|
|
49
|
+
|
|
50
|
+
### Changed
|
|
51
|
+
|
|
52
|
+
- `hibiki.js` gains three seams for the module: an exported `islandFor(element)`,
|
|
53
|
+
the island's `lastControl` (set in `trackControl`, kept past the trip's
|
|
54
|
+
settle), and a `hibiki:before-render` event dispatched from the island root
|
|
55
|
+
before a transmit swap, with `event.detail.render` replaceable the way
|
|
56
|
+
Turbo's `turbo:before-stream-render` is. `received` returns the render's
|
|
57
|
+
result: `undefined` for a synchronous swap, a promise for a held one. An ack
|
|
58
|
+
settles its trip even while a render is held.
|
|
59
|
+
- `CssVariant` gains two tokens, `row_card` and `contents`.
|
|
60
|
+
|
|
61
|
+
[Motion]: https://planetaska.github.io/hibiki/motion/
|
|
62
|
+
|
|
63
|
+
## 0.14.0 — 2026-09-07
|
|
64
|
+
|
|
65
|
+
### Changed
|
|
66
|
+
|
|
67
|
+
**The infinite-scroll Load-more control has a degraded path.** Under
|
|
68
|
+
`--infinite-scroll` the scaffold emitted one button that answered both a
|
|
69
|
+
click and the `visible` sentinel event, and gave it no `fallback:` on
|
|
70
|
+
purpose: a sentinel that navigates on a dead socket would make scrolling
|
|
71
|
+
navigate. Without JavaScript the button rendered and did nothing. The control
|
|
72
|
+
is now two elements. The wrapper (`books_load_more`, the same id) is the
|
|
73
|
+
sentinel and carries only `visible`. A link inside it is the click path, with
|
|
74
|
+
`fallback: true` and a real href: the query already reads `?page=N` as a
|
|
75
|
+
growing window, so a degraded click shows every window up to N, and the
|
|
76
|
+
link's fragment lands on the last row the reader had already seen. The JS
|
|
77
|
+
client is unchanged.
|
|
78
|
+
|
|
79
|
+
- Both view layers. The Phlex component uses a bare `a`, so no `LinkTo`
|
|
80
|
+
include; `page_url` is now emitted in both pagination modes.
|
|
81
|
+
- Regenerating an app changes only `_list.html.erb` / `list.rb`. A system
|
|
82
|
+
test that clicked `#books_load_more button` now clicks the `a`.
|
|
83
|
+
- Docs: the fallback contract in [The JS client] gains the rule — never give
|
|
84
|
+
a `visible` control a `fallback:`; split it as the scaffold does.
|
|
85
|
+
|
|
7
86
|
## 0.13.0 — 2026-09-07
|
|
8
87
|
|
|
9
88
|
### Changed
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// hibiki-rails/motion — enter/leave motion for server-rendered swaps.
|
|
2
|
+
//
|
|
3
|
+
// Opt-in: `import "hibiki-rails/motion"` once, beside the controllers. This
|
|
4
|
+
// is the PUBLIC motion contract (unlike the client's private data-hibiki-*):
|
|
5
|
+
//
|
|
6
|
+
// app-written data-motion the element animates its leave
|
|
7
|
+
// data-motion="own" …only when the gesture that removed
|
|
8
|
+
// it came from inside it — a destroyed
|
|
9
|
+
// row, not one that paged or filtered
|
|
10
|
+
// away. Marked elements need an id: both
|
|
11
|
+
// sets below are keyed by id, as the
|
|
12
|
+
// transports' swaps are.
|
|
13
|
+
// written here data-motion-leaving on a marked element the incoming
|
|
14
|
+
// render lacks, until its transitions end
|
|
15
|
+
// data-motion-entering on a marked element the render inserted,
|
|
16
|
+
// until its first transitions end
|
|
17
|
+
//
|
|
18
|
+
// Addressed to app CSS — Tailwind's `starting:` / `data-motion-leaving:` /
|
|
19
|
+
// `in-data-motion-leaving:` variants, or plain attribute selectors. The
|
|
20
|
+
// enter needs no help: @starting-style is the from-state on a real
|
|
21
|
+
// insertion. The exit does, because a morph drops the node before any CSS
|
|
22
|
+
// could run — so a render that would remove a marked element is HELD until
|
|
23
|
+
// the element's transitions finish. Renders queue in arrival order, one
|
|
24
|
+
// chain per document, so a hold never lets a later render overtake it. Only
|
|
25
|
+
// the outermost marked element of a subtree moves, in either phase.
|
|
26
|
+
//
|
|
27
|
+
// One requestAnimationFrame per wait, because transitions exist only after
|
|
28
|
+
// the next style pass. A hidden document waits for nothing (its frames never
|
|
29
|
+
// come), and every wait races a one-second ceiling, so a tab hidden mid-hold
|
|
30
|
+
// cannot wedge the queue. Nothing here reads layout: a forced style read
|
|
31
|
+
// between a morph's insert and its class sync defeats @starting-style.
|
|
32
|
+
import { islandFor } from "hibiki-rails"
|
|
33
|
+
|
|
34
|
+
const CEILING = 1000
|
|
35
|
+
|
|
36
|
+
// Turbo stream actions that remove content. morph rides replace/update;
|
|
37
|
+
// append/prepend/before/after/refresh remove nothing.
|
|
38
|
+
const REMOVING = new Set(["replace", "update", "remove"])
|
|
39
|
+
|
|
40
|
+
const marked = (root) => [...root.querySelectorAll("[data-motion]")]
|
|
41
|
+
const hasMarks = (root) => root.querySelector("[data-motion]") !== null
|
|
42
|
+
const idsOf = (root) => new Set(marked(root).map((element) => element.id))
|
|
43
|
+
|
|
44
|
+
// `own`: the island's trip records are the honest gesture signal — the
|
|
45
|
+
// control of an in-flight trip, or the last control the island tracked
|
|
46
|
+
// (kept past its trip's settle; a render can land after the ack's grace).
|
|
47
|
+
const ownsGesture = (island, element) => {
|
|
48
|
+
if (!island) return false
|
|
49
|
+
for (const record of island.busy.values()) {
|
|
50
|
+
if (record.control && element.contains(record.control)) return true
|
|
51
|
+
}
|
|
52
|
+
return Boolean(island.lastControl) && element.contains(island.lastControl)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const animates = (element, island) =>
|
|
56
|
+
element.getAttribute("data-motion") !== "own" || ownsGesture(island, element)
|
|
57
|
+
|
|
58
|
+
// Marked elements under `target` (and `target` itself when the action
|
|
59
|
+
// replaces it) whose id the incoming content lacks.
|
|
60
|
+
const leavingFrom = (target, includeSelf, incoming, island) => {
|
|
61
|
+
const candidates =
|
|
62
|
+
includeSelf && target.hasAttribute("data-motion") ? [target, ...marked(target)] : marked(target)
|
|
63
|
+
return candidates.filter(
|
|
64
|
+
(element) => element.id && !incoming.has(element.id) && animates(element, island)
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const outermost = (elements) =>
|
|
69
|
+
elements.filter((element) => !elements.some((other) => other !== element && other.contains(element)))
|
|
70
|
+
|
|
71
|
+
// Resolves once the element's own transitions have run. Element-only, not
|
|
72
|
+
// {subtree: true}: a spinner inside a leaving row is an infinite animation.
|
|
73
|
+
const settled = async (element) => {
|
|
74
|
+
if (document.visibilityState === "hidden") return
|
|
75
|
+
await new Promise(requestAnimationFrame)
|
|
76
|
+
await Promise.allSettled((element.getAnimations?.() ?? []).map((animation) => animation.finished))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const finished = (elements) => {
|
|
80
|
+
let timer
|
|
81
|
+
const ceiling = new Promise((resolve) => {
|
|
82
|
+
timer = setTimeout(resolve, CEILING)
|
|
83
|
+
})
|
|
84
|
+
return Promise.race([Promise.all(elements.map(settled)), ceiling]).finally(() => clearTimeout(timer))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// One chain per document. A failed render must not close it.
|
|
88
|
+
let queue = Promise.resolve()
|
|
89
|
+
const enqueue = (job) => (queue = queue.catch(() => {}).then(job))
|
|
90
|
+
|
|
91
|
+
// The hold. `plan` runs when the render's turn comes (earlier renders may
|
|
92
|
+
// have changed the DOM) and returns { leaving, islands }; `render` is what
|
|
93
|
+
// the transport would have run at once.
|
|
94
|
+
const held = (plan, render) =>
|
|
95
|
+
enqueue(async () => {
|
|
96
|
+
const { leaving: candidates, islands } = plan()
|
|
97
|
+
const leaving = outermost(candidates)
|
|
98
|
+
const before = idsOf(document)
|
|
99
|
+
for (const element of leaving) element.setAttribute("data-motion-leaving", "")
|
|
100
|
+
if (leaving.length) await finished(leaving)
|
|
101
|
+
try {
|
|
102
|
+
await render()
|
|
103
|
+
} finally {
|
|
104
|
+
// A render that kept the element after all, and the gesture consumed.
|
|
105
|
+
for (const element of leaving) {
|
|
106
|
+
if (element.isConnected) element.removeAttribute("data-motion-leaving")
|
|
107
|
+
}
|
|
108
|
+
for (const island of islands) island.lastControl = null
|
|
109
|
+
}
|
|
110
|
+
const entering = outermost(marked(document).filter((element) => element.id && !before.has(element.id)))
|
|
111
|
+
for (const element of entering) {
|
|
112
|
+
element.setAttribute("data-motion-entering", "")
|
|
113
|
+
finished([element]).then(() => element.removeAttribute("data-motion-entering"))
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// Turbo streams. Registered at import, so this wrapper sits INSIDE the
|
|
118
|
+
// islands' own render wrappers (theirs count the paint) and the hold ends
|
|
119
|
+
// before they count it. The gate keeps unmarked pages at two querySelectors;
|
|
120
|
+
// a hold is pending only while a marked element is still in the document,
|
|
121
|
+
// so any render arriving during one sees marks and queues behind it.
|
|
122
|
+
document.addEventListener("turbo:before-stream-render", (event) => {
|
|
123
|
+
const stream = event.detail.newStream
|
|
124
|
+
if (!stream?.templateElement) return
|
|
125
|
+
const content = stream.templateElement.content
|
|
126
|
+
if (!hasMarks(document) && !hasMarks(content)) return
|
|
127
|
+
|
|
128
|
+
const render = event.detail.render
|
|
129
|
+
event.detail.render = (streamElement) =>
|
|
130
|
+
held(() => {
|
|
131
|
+
const plan = { leaving: [], islands: new Set() }
|
|
132
|
+
// A refresh stream has no target: targetElements throws.
|
|
133
|
+
if (stream.action === "refresh") return plan
|
|
134
|
+
const incoming = idsOf(content)
|
|
135
|
+
for (const target of stream.targetElements) {
|
|
136
|
+
const island = islandFor(target)
|
|
137
|
+
if (island) plan.islands.add(island)
|
|
138
|
+
if (REMOVING.has(stream.action)) {
|
|
139
|
+
plan.leaving.push(...leavingFrom(target, stream.action !== "update", incoming, island))
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return plan
|
|
143
|
+
}, () => render(streamElement))
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// The transmit transport: the client's own swap, by root id. A root id
|
|
147
|
+
// always survives a by-id swap, so only descendants can leave.
|
|
148
|
+
document.addEventListener("hibiki:before-render", (event) => {
|
|
149
|
+
const { island, content, render } = event.detail
|
|
150
|
+
if (!hasMarks(document) && !hasMarks(content)) return
|
|
151
|
+
|
|
152
|
+
event.detail.render = () =>
|
|
153
|
+
held(() => ({
|
|
154
|
+
islands: new Set([island]),
|
|
155
|
+
leaving: [...content.children].flatMap((fragment) => {
|
|
156
|
+
const target = document.getElementById(fragment.id)
|
|
157
|
+
return target ? leavingFrom(target, false, idsOf(fragment), island) : []
|
|
158
|
+
})
|
|
159
|
+
}), render)
|
|
160
|
+
})
|
|
@@ -71,6 +71,14 @@
|
|
|
71
71
|
// and per-button feedback needs no server state. Content is stale during
|
|
72
72
|
// a round trip, never absent.
|
|
73
73
|
//
|
|
74
|
+
// Motion is the one PUBLIC data-* contract, and it lives in the opt-in
|
|
75
|
+
// module `hibiki-rails/motion`: an app marks an element `data-motion` (or
|
|
76
|
+
// `data-motion="own"`), the module stamps `data-motion-leaving` /
|
|
77
|
+
// `data-motion-entering` around a swap and holds the render while a
|
|
78
|
+
// leaving element's transitions run. Its header carries the rules; this
|
|
79
|
+
// file only gives it two seams — `islandFor` and the `hibiki:before-render`
|
|
80
|
+
// event the transmit swap dispatches (the same shape Turbo gives streams).
|
|
81
|
+
//
|
|
74
82
|
// The left side of `->` is a hibiki event name: click, change, input,
|
|
75
83
|
// and submit are delegated DOM listeners; `visible` is a pseudo-event
|
|
76
84
|
// backed by an IntersectionObserver (the element entering the viewport).
|
|
@@ -131,6 +139,18 @@ let consumer
|
|
|
131
139
|
// island pins nothing even if disconnect never ran.
|
|
132
140
|
const islands = new WeakMap()
|
|
133
141
|
|
|
142
|
+
// The generic island CONTAINING element, or undefined. The walk performOn
|
|
143
|
+
// uses, exported for the motion module's `own` policy — which reads the
|
|
144
|
+
// island's trip records, so a ChannelController subclass (not in the map)
|
|
145
|
+
// gets plain marks only.
|
|
146
|
+
export function islandFor(element) {
|
|
147
|
+
for (let node = element; node; node = node.parentElement) {
|
|
148
|
+
const island = islands.get(node)
|
|
149
|
+
if (island) return island
|
|
150
|
+
}
|
|
151
|
+
return undefined
|
|
152
|
+
}
|
|
153
|
+
|
|
134
154
|
// camelCase Stimulus method name → snake_case Ruby channel action.
|
|
135
155
|
const underscore = (name) => name.replace(/([A-Z])/g, "_$1").toLowerCase()
|
|
136
156
|
|
|
@@ -228,6 +248,7 @@ export class ChannelController extends Controller {
|
|
|
228
248
|
this.seq = 0
|
|
229
249
|
this.renders = 0
|
|
230
250
|
this.busy = new Map()
|
|
251
|
+
this.lastControl = null
|
|
231
252
|
this.queued = []
|
|
232
253
|
this.setState("connecting")
|
|
233
254
|
}
|
|
@@ -348,7 +369,11 @@ export class ChannelController extends Controller {
|
|
|
348
369
|
// A depth counter, not a boolean: typing while a page loads is one island
|
|
349
370
|
// with two actions outstanding. No requestAnimationFrame anywhere — it
|
|
350
371
|
// never fires in a background tab, which is exactly when a stuck
|
|
351
|
-
// indicator goes unnoticed.
|
|
372
|
+
// indicator goes unnoticed. (The motion module is the one place a frame
|
|
373
|
+
// is waited for, and it waits for nothing in a hidden document.) An ack
|
|
374
|
+
// settles a trip even while that module is holding the render: the flag
|
|
375
|
+
// means the server has not answered, and a hold is motion after the
|
|
376
|
+
// answer.
|
|
352
377
|
|
|
353
378
|
beginBusy(seq) {
|
|
354
379
|
const record = {
|
|
@@ -373,6 +398,11 @@ export class ChannelController extends Controller {
|
|
|
373
398
|
// has none); controls outside it — the search field — need the removal
|
|
374
399
|
// in settle().
|
|
375
400
|
trackControl(seq, control) {
|
|
401
|
+
// The most recent gesture, kept past its trip's settle for the motion
|
|
402
|
+
// module's `own` policy (a render can land after the ack's grace). Set
|
|
403
|
+
// before the record check: a send that died on the socket was still a
|
|
404
|
+
// gesture. A ChannelController subclass wanting `own` calls this itself.
|
|
405
|
+
this.lastControl = control
|
|
376
406
|
const record = this.busy.get(seq)
|
|
377
407
|
if (!record) return
|
|
378
408
|
record.control = control
|
|
@@ -453,7 +483,13 @@ export class ChannelController extends Controller {
|
|
|
453
483
|
//
|
|
454
484
|
// { url } — mirror graph state into the address bar (transmit_url).
|
|
455
485
|
//
|
|
456
|
-
// { html } — a fragment: swap it in by its root id.
|
|
486
|
+
// { html } — a fragment: swap it in by its root id. The swap is holdable
|
|
487
|
+
// the way Turbo's is: `hibiki:before-render` bubbles from the island
|
|
488
|
+
// first (detail { island, content, render }), and a listener may replace
|
|
489
|
+
// detail.render with one that waits — the motion module does, to let a
|
|
490
|
+
// leaving element's transition finish. Returns whatever that render
|
|
491
|
+
// returns: undefined when the swap ran synchronously, a promise when it
|
|
492
|
+
// was held, which is what a subclass reading the DOM afterwards awaits.
|
|
457
493
|
//
|
|
458
494
|
// Anything else is not ours to interpret. Subclasses may override, but
|
|
459
495
|
// should call super (or handle `value`) to keep reactive values live.
|
|
@@ -469,9 +505,18 @@ export class ChannelController extends Controller {
|
|
|
469
505
|
if (!html) return
|
|
470
506
|
const template = document.createElement("template")
|
|
471
507
|
template.innerHTML = html
|
|
472
|
-
|
|
473
|
-
|
|
508
|
+
const fragments = [...template.content.children]
|
|
509
|
+
const render = () => {
|
|
510
|
+
for (const fragment of fragments) {
|
|
511
|
+
document.getElementById(fragment.id)?.replaceWith(fragment)
|
|
512
|
+
}
|
|
474
513
|
}
|
|
514
|
+
const event = new CustomEvent("hibiki:before-render", {
|
|
515
|
+
bubbles: true,
|
|
516
|
+
detail: { island: this, content: template.content, render }
|
|
517
|
+
})
|
|
518
|
+
this.element.dispatchEvent(event)
|
|
519
|
+
return event.detail.render()
|
|
475
520
|
}
|
|
476
521
|
|
|
477
522
|
// replaceState, never pushState: the URL is a mirror of graph state, not
|
|
@@ -629,10 +674,13 @@ export default class HibikiController extends ChannelController {
|
|
|
629
674
|
super.disconnect()
|
|
630
675
|
}
|
|
631
676
|
|
|
632
|
-
// The other swap point: hibiki's own transmit transport.
|
|
677
|
+
// The other swap point: hibiki's own transmit transport. A held swap
|
|
678
|
+
// hands back a promise; the replacement sentinel exists only after it.
|
|
633
679
|
received(data) {
|
|
634
|
-
super.received(data)
|
|
635
|
-
if (data.html)
|
|
680
|
+
const swapped = super.received(data)
|
|
681
|
+
if (!data.html) return
|
|
682
|
+
if (swapped?.then) swapped.then(() => this.scanSentinels())
|
|
683
|
+
else this.scanSentinels()
|
|
636
684
|
}
|
|
637
685
|
|
|
638
686
|
// Observe every `visible->` sentinel this island owns. observe() is a
|
|
@@ -771,10 +819,8 @@ export { HibikiController }
|
|
|
771
819
|
// element (a structural mistake, hence the warn; the offline case stays
|
|
772
820
|
// quiet because it is expected weather).
|
|
773
821
|
export function performOn(element, action, payload = {}) {
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
if (island) return island.perform(action, payload)
|
|
777
|
-
}
|
|
822
|
+
const island = islandFor(element)
|
|
823
|
+
if (island) return island.perform(action, payload)
|
|
778
824
|
console.warn("hibiki: performOn found no island containing", element)
|
|
779
825
|
return undefined
|
|
780
826
|
}
|
data/config/importmap.rb
CHANGED
|
@@ -6,3 +6,5 @@
|
|
|
6
6
|
# — both are part of the Rails 8 default stack. The client takes Action
|
|
7
7
|
# Cable from turbo-rails' consumer, so no "@rails/actioncable" pin is needed.
|
|
8
8
|
pin "hibiki-rails", to: "hibiki.js"
|
|
9
|
+
# The opt-in motion module; it imports "hibiki-rails" by the pin above.
|
|
10
|
+
pin "hibiki-rails/motion", to: "hibiki-motion.js"
|
|
@@ -57,6 +57,11 @@ module Hibiki
|
|
|
57
57
|
card: "card bg-base-100 shadow-sm",
|
|
58
58
|
card_body: "card-body",
|
|
59
59
|
card_actions: "card-actions justify-end",
|
|
60
|
+
# The row under motion: card and body as ONE element, so the sliding
|
|
61
|
+
# wrapper above it adds no depth to the row's content. And the
|
|
62
|
+
# display wrapper, a swap unit with no box of its own.
|
|
63
|
+
row_card: "card bg-base-100 shadow-sm card-body",
|
|
64
|
+
contents: "contents",
|
|
60
65
|
toggle: "toggle",
|
|
61
66
|
toggle_sm: "toggle toggle-sm",
|
|
62
67
|
btn: "btn",
|
|
@@ -149,6 +154,7 @@ module Hibiki
|
|
|
149
154
|
card: "rounded-lg border border-gray-200 bg-white shadow-sm",
|
|
150
155
|
card_body: "p-6 space-y-2",
|
|
151
156
|
card_actions: "flex flex-wrap justify-end gap-2 pt-4",
|
|
157
|
+
row_card: "rounded-lg border border-gray-200 bg-white shadow-sm p-6 space-y-2",
|
|
152
158
|
toggle: CHECKBOX,
|
|
153
159
|
toggle_sm: CHECKBOX,
|
|
154
160
|
btn: SECONDARY_BUTTON,
|
|
@@ -25,8 +25,11 @@ Example:
|
|
|
25
25
|
app/controllers/books_controller.rb
|
|
26
26
|
app/views/books/*
|
|
27
27
|
app/assets/stylesheets/hibiki_busy.css
|
|
28
|
+
app/assets/stylesheets/hibiki_motion.css
|
|
28
29
|
And add to config/routes.rb:
|
|
29
30
|
resources :books
|
|
31
|
+
And to app/javascript/application.js:
|
|
32
|
+
import "hibiki-rails/motion"
|
|
30
33
|
|
|
31
34
|
Restart the server afterwards if it is running — app/forms and
|
|
32
35
|
app/queries are new.
|
|
@@ -34,3 +37,7 @@ Example:
|
|
|
34
37
|
Pass --phlex to emit Phlex components under app/views/books/*.rb instead of
|
|
35
38
|
ERB templates. The view layer is the only thing it changes; it needs the
|
|
36
39
|
phlex-rails gem and `bin/rails g phlex:install`.
|
|
40
|
+
|
|
41
|
+
Rows and inline forms slide open and shut. Pass --skip-motion to leave
|
|
42
|
+
that out: no motion stylesheet, no import, no marks in the views.
|
|
43
|
+
--css=none never emits it either.
|
|
@@ -46,6 +46,8 @@ module Hibiki
|
|
|
46
46
|
desc: "Omit the search box and the LIKE terms behind it"
|
|
47
47
|
class_option :skip_create, type: :boolean, default: false,
|
|
48
48
|
desc: "Omit the inline create form — New always navigates"
|
|
49
|
+
class_option :skip_motion, type: :boolean, default: false,
|
|
50
|
+
desc: "Omit the enter/leave motion — no hibiki_motion.css, no marks"
|
|
49
51
|
class_option :page_size, type: :numeric, default: 20, desc: "Rows per page"
|
|
50
52
|
class_option :phlex, type: :boolean, default: false,
|
|
51
53
|
desc: "Emit Phlex components under app/views instead of ERB templates"
|
|
@@ -24,11 +24,17 @@ Example:
|
|
|
24
24
|
app/controllers/books_controller.rb
|
|
25
25
|
app/views/books/*
|
|
26
26
|
app/assets/stylesheets/hibiki_busy.css
|
|
27
|
+
app/assets/stylesheets/hibiki_motion.css
|
|
27
28
|
And modify:
|
|
28
29
|
app/models/book.rb
|
|
29
30
|
app/models/author.rb # each model a belongs_to points at
|
|
31
|
+
app/javascript/application.js # imports hibiki-rails/motion
|
|
30
32
|
config/routes.rb
|
|
31
33
|
|
|
32
34
|
Pass --phlex to emit Phlex components under app/views/books/*.rb instead of
|
|
33
35
|
ERB templates. The view layer is the only thing it changes; it needs the
|
|
34
36
|
phlex-rails gem and `bin/rails g phlex:install`.
|
|
37
|
+
|
|
38
|
+
Rows and inline forms slide open and shut. Pass --skip-motion to leave
|
|
39
|
+
that out: no motion stylesheet, no import, no marks in the views.
|
|
40
|
+
--css=none never emits it either.
|
|
@@ -12,6 +12,7 @@ require_relative "../scaffold_phlex_helpers"
|
|
|
12
12
|
require_relative "../scaffold_post_install"
|
|
13
13
|
require_relative "../scaffold_shared_views"
|
|
14
14
|
require_relative "../scaffold_transport_stylesheet"
|
|
15
|
+
require_relative "../scaffold_motion"
|
|
15
16
|
require_relative "../scaffold_schema"
|
|
16
17
|
require_relative "../css_variant"
|
|
17
18
|
|
|
@@ -39,6 +40,7 @@ module Hibiki
|
|
|
39
40
|
include ScaffoldPostInstall
|
|
40
41
|
include ScaffoldSharedViews
|
|
41
42
|
include ScaffoldTransportStylesheet
|
|
43
|
+
include ScaffoldMotion
|
|
42
44
|
|
|
43
45
|
TEMPLATE_ROOT = File.expand_path("templates", __dir__)
|
|
44
46
|
|
|
@@ -59,6 +61,8 @@ module Hibiki
|
|
|
59
61
|
desc: "Omit the search box and the LIKE terms behind it"
|
|
60
62
|
class_option :skip_create, type: :boolean, default: false,
|
|
61
63
|
desc: "Omit the inline create form — New always navigates"
|
|
64
|
+
class_option :skip_motion, type: :boolean, default: false,
|
|
65
|
+
desc: "Omit the enter/leave motion — no hibiki_motion.css, no marks"
|
|
62
66
|
class_option :page_size, type: :numeric, default: 20,
|
|
63
67
|
desc: "Rows per page"
|
|
64
68
|
class_option :skip_routes, type: :boolean,
|
|
@@ -89,14 +93,19 @@ module Hibiki
|
|
|
89
93
|
@new_app_dirs = %w[app/forms app/queries app/channels app/models app/views].reject { exists?(it) }
|
|
90
94
|
end
|
|
91
95
|
|
|
92
|
-
# One file per app, not per resource, and the only
|
|
93
|
-
# writes outside app/{channels,models,forms,views,
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
+
# One file per app, not per resource (two under motion), and the only
|
|
97
|
+
# thing this generator writes outside app/{channels,models,forms,views,
|
|
98
|
+
# controllers} besides the motion import. They carry no model knowledge
|
|
99
|
+
# at all — the rules key on the client's attributes — so a second
|
|
100
|
+
# resource finds them already there.
|
|
96
101
|
def create_stylesheet
|
|
97
102
|
create_transport_stylesheet
|
|
98
103
|
end
|
|
99
104
|
|
|
105
|
+
def wire_motion
|
|
106
|
+
wire_motion_module
|
|
107
|
+
end
|
|
108
|
+
|
|
100
109
|
def create_channels
|
|
101
110
|
template "channel.rb.tt", collection_channel_path
|
|
102
111
|
template "member_channel.rb.tt", member_channel_path
|
|
@@ -292,6 +301,10 @@ module Hibiki
|
|
|
292
301
|
def css(token) = CssVariant.token(css_variant, token)
|
|
293
302
|
def css? = css_variant != :none
|
|
294
303
|
|
|
304
|
+
# Motion rides the stylesheet: --css=none is a hand-styled app, and its
|
|
305
|
+
# views must stay byte-identical to a --skip-motion run.
|
|
306
|
+
def motion? = css? && !options[:skip_motion]
|
|
307
|
+
|
|
295
308
|
# nil means "no window": .limit(nil) and .offset(nil) are both relation
|
|
296
309
|
# no-ops, so one constant replaces template conditionals across five
|
|
297
310
|
# files.
|
data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/list.rb.tt
CHANGED
|
@@ -48,8 +48,15 @@ class <%= view_class_name(:list) %> < Views::Base
|
|
|
48
48
|
<% if inline_create? -%>
|
|
49
49
|
|
|
50
50
|
def create_form
|
|
51
|
+
<% if motion? -%>
|
|
52
|
+
# data-motion: the client holds the card while it slides shut; the slide
|
|
53
|
+
# itself is hbk-slide in hibiki_motion.css.
|
|
54
|
+
div<%= arg_list(%(id: "#{row_dom_id_prefix}_new"), "data: { motion: true }", *hbk_args("hbk-slide", token: :card)) %> do
|
|
55
|
+
div<%= arg_list(*hbk_args("hbk-slide-body", token: :card_body)) %> do
|
|
56
|
+
<% else -%>
|
|
51
57
|
div<%= arg_list(%(id: "#{row_dom_id_prefix}_new"), *css_args(:card)) %> do
|
|
52
58
|
div<%= arg_list(*css_args(:card_body)) %> do
|
|
59
|
+
<% end -%>
|
|
53
60
|
<%= render_call(:row_form, create_form_render_args, indent: 8) %>
|
|
54
61
|
end
|
|
55
62
|
end
|
|
@@ -61,10 +68,11 @@ class <%= view_class_name(:list) %> < Views::Base
|
|
|
61
68
|
pages = <%= query_class_name %>.page_numbers(page: @page, page_count: @page_count)
|
|
62
69
|
<%= render_call(:pagination, ["page: @page", "page_count: @page_count", "pages: pages", %(anchor: "##{list_dom_id}"), "id: id", "url: page_url"], indent: 4, shared: true) %>
|
|
63
70
|
end
|
|
71
|
+
<% end -%>
|
|
64
72
|
|
|
65
|
-
# Real
|
|
66
|
-
# re-renders them with url_params fresh, so the
|
|
67
|
-
# truthful as the live state moves.
|
|
73
|
+
# Real hrefs for the <%= infinite? ? "Load-more link" : "page links" %>, carrying the current query
|
|
74
|
+
# state — the channel re-renders them with url_params fresh, so the
|
|
75
|
+
# fallback URLs stay truthful as the live state moves.
|
|
68
76
|
def page_url
|
|
69
77
|
->(n) do
|
|
70
78
|
query = @url_params.except(:page)
|
|
@@ -72,19 +80,22 @@ class <%= view_class_name(:list) %> < Views::Base
|
|
|
72
80
|
<%= index_helper(type: :path) %>(**query)
|
|
73
81
|
end
|
|
74
82
|
end
|
|
75
|
-
<% end -%>
|
|
76
83
|
<% if infinite? -%>
|
|
77
84
|
|
|
78
|
-
# `visible` is a pseudo-event backed by an IntersectionObserver
|
|
79
|
-
#
|
|
80
|
-
#
|
|
85
|
+
# `visible` is a pseudo-event backed by an IntersectionObserver: the wrapper
|
|
86
|
+
# is the scroll sentinel, the link inside it is the click path and its href
|
|
87
|
+
# the fallback. A sentinel must never fall back — scrolling into view on a
|
|
88
|
+
# dead socket would navigate.
|
|
81
89
|
def load_more
|
|
82
|
-
div<%= arg_list(%(id: "#{load_more_dom_id}"), *css_args(:load_more_wrap)) %> do
|
|
83
|
-
|
|
90
|
+
div<%= arg_list(%(id: "#{load_more_dom_id}"), *css_args(:load_more_wrap), "**on(:go_to_page, event: :visible, with: { page: @page + 1 })", indent: 8) %> do
|
|
91
|
+
# The STRING "false", not the boolean — Phlex omits false-valued attributes.
|
|
92
|
+
a<%= arg_list("href: load_more_href", *css_args(:btn_wide), "data: { turbo: \"false\" }.merge(on(:go_to_page, with: { page: @page + 1 },\n#{' ' * 42}fallback: true)[:data])", indent: 8) %> do
|
|
84
93
|
plain "Load more"
|
|
85
94
|
span<%= arg_list(*css_args(:muted_inline)) %> { " · #{@remaining} remaining" }
|
|
86
95
|
end
|
|
87
96
|
end
|
|
88
97
|
end
|
|
98
|
+
|
|
99
|
+
def load_more_href = "#{page_url.(@page + 1)}#<%= row_dom_id_prefix %>_#{@<%= controller_file_name %>.last.id}"
|
|
89
100
|
<% end -%>
|
|
90
101
|
end
|
|
@@ -19,18 +19,47 @@ class <%= view_class_name(:row) %> < Views::Base
|
|
|
19
19
|
def view_template
|
|
20
20
|
# The root id stays stable across display/edit, so idiomorph pairs the
|
|
21
21
|
# subtrees instead of rebuilding them (and losing focus).
|
|
22
|
+
<% if motion? -%>
|
|
23
|
+
# data-motion="own": the row slides out when its own Destroy removes it;
|
|
24
|
+
# rows that page or filter away go at once. The wrapper closes the gap
|
|
25
|
+
# while the card inside slides left (hbk-slide-x, hibiki_motion.css).
|
|
26
|
+
div<%= arg_list(%(id: "#{row_dom_id_prefix}_\#{#{record_ref}.id}"), %(data: { motion: "own" }), *hbk_args("hbk-slide-x")) %> do
|
|
27
|
+
div<%= arg_list(*hbk_args("hbk-slide-x-body", token: :row_card)) %> do
|
|
28
|
+
@editing ? edit_form : display
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
<% else -%>
|
|
22
32
|
div<%= arg_list(%(id: "#{row_dom_id_prefix}_\#{#{record_ref}.id}"), *css_args(:card)) %> do
|
|
23
33
|
div<%= arg_list(*css_args(:card_body)) %> do
|
|
24
34
|
@editing ? edit_form : fields
|
|
25
35
|
end
|
|
26
36
|
end
|
|
37
|
+
<% end -%>
|
|
27
38
|
end
|
|
28
39
|
|
|
29
40
|
private
|
|
30
41
|
|
|
42
|
+
<% if motion? -%>
|
|
43
|
+
# The edit and display wrappers carry distinct ids so idiomorph swaps them
|
|
44
|
+
# instead of morphing one into the other — starting: fires only on a real
|
|
45
|
+
# insertion. The client holds the edit wrapper while it slides shut.
|
|
46
|
+
def edit_form
|
|
47
|
+
div<%= arg_list(%(id: "#{row_dom_id_prefix}_\#{#{record_ref}.id}_edit"), "data: { motion: true }", *hbk_args("hbk-slide")) %> do
|
|
48
|
+
div<%= arg_list(*hbk_args("hbk-slide-body")) %> do
|
|
49
|
+
<%= render_call(:row_form, row_form_render_args, indent: 8) %>
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# contents: no box of its own, so the card's layout is unchanged.
|
|
55
|
+
def display
|
|
56
|
+
div<%= arg_list(%(id: "#{row_dom_id_prefix}_\#{#{record_ref}.id}_display"), *css_args(:contents)) %> { fields }
|
|
57
|
+
end
|
|
58
|
+
<% else -%>
|
|
31
59
|
def edit_form
|
|
32
60
|
<%= render_call(:row_form, row_form_render_args, indent: 4) %>
|
|
33
61
|
end
|
|
62
|
+
<% end -%>
|
|
34
63
|
|
|
35
64
|
def fields
|
|
36
65
|
<% schema.columns.each do |column| -%>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<%#- ESCAPING: all of this is literal CSS. There is no runtime ERB here at all —
|
|
2
|
+
a .css file is never rendered — and no generation-time branch either: this
|
|
3
|
+
file is not installed under --css=none. -%>
|
|
4
|
+
/* The app's half of the motion contract: the hibiki-rails/motion module
|
|
5
|
+
stamps data-motion-leaving / data-motion-entering on a marked element
|
|
6
|
+
around a swap, and these classes turn that into motion. Only ever READ
|
|
7
|
+
those attributes — never write them.
|
|
8
|
+
|
|
9
|
+
One file per app. Duration and easing are tuned HERE — the views carry the
|
|
10
|
+
class names only. @utility, not a plain class: that puts each one in the
|
|
11
|
+
utilities layer, above daisyUI's components (a .card would otherwise win
|
|
12
|
+
on display and transition), and Tailwind orders multi-property utilities
|
|
13
|
+
first, so a single utility on the element (duration-500) still wins.
|
|
14
|
+
Needs Tailwind >= 4.1 (the in-* variant). */
|
|
15
|
+
|
|
16
|
+
/* Slide open on insertion, shut on removal. The body class goes on the grid
|
|
17
|
+
child: the collapsed height's floor is its padding, so that slides too.
|
|
18
|
+
mb-0 keeps a space-y-* list's margin from jumping at either end. */
|
|
19
|
+
@utility hbk-slide {
|
|
20
|
+
@apply grid grid-rows-[1fr] transition-all duration-300 ease-out
|
|
21
|
+
starting:grid-rows-[0fr] starting:opacity-0 starting:mb-0
|
|
22
|
+
data-motion-leaving:grid-rows-[0fr] data-motion-leaving:opacity-0
|
|
23
|
+
data-motion-leaving:mb-0 data-motion-leaving:ease-in
|
|
24
|
+
data-motion-entering:overflow-hidden data-motion-leaving:overflow-hidden
|
|
25
|
+
motion-reduce:transition-none;
|
|
26
|
+
}
|
|
27
|
+
@utility hbk-slide-body {
|
|
28
|
+
@apply min-h-0 transition-[padding] duration-300 ease-out
|
|
29
|
+
starting:py-0 in-data-motion-leaving:py-0 in-data-motion-leaving:ease-in;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* Slide out sideways, then close the gap — the swipe-to-delete shape, for a
|
|
33
|
+
removal only. Two boxes: the wrapper closes the gap on a delay, and the
|
|
34
|
+
body (the visible card) slides left and fades first. */
|
|
35
|
+
@utility hbk-slide-x {
|
|
36
|
+
@apply grid grid-rows-[1fr] transition-[grid-template-rows,margin] duration-300 delay-200 ease-out
|
|
37
|
+
data-motion-leaving:grid-rows-[0fr] data-motion-leaving:mb-0 data-motion-leaving:overflow-hidden
|
|
38
|
+
motion-reduce:transition-none;
|
|
39
|
+
}
|
|
40
|
+
@utility hbk-slide-x-body {
|
|
41
|
+
@apply min-h-0 transition-[translate,opacity] duration-300 ease-in
|
|
42
|
+
in-data-motion-leaving:-translate-x-full in-data-motion-leaving:opacity-0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* The rest of the set. Only the slides animate height; these leave the
|
|
46
|
+
neighbours jumping. Parameters are custom properties, overridable on the
|
|
47
|
+
element: class="hbk-fly [--hbk-fly-y:2rem]". */
|
|
48
|
+
@utility hbk-fade {
|
|
49
|
+
@apply transition-opacity duration-300
|
|
50
|
+
starting:opacity-0 data-motion-leaving:opacity-0
|
|
51
|
+
motion-reduce:transition-none;
|
|
52
|
+
}
|
|
53
|
+
@utility hbk-fly {
|
|
54
|
+
@apply [--hbk-fly-x:0px] [--hbk-fly-y:1rem] transition-[translate,opacity] duration-300 ease-out
|
|
55
|
+
starting:opacity-0 starting:translate-x-(--hbk-fly-x) starting:translate-y-(--hbk-fly-y)
|
|
56
|
+
data-motion-leaving:opacity-0 data-motion-leaving:translate-x-(--hbk-fly-x) data-motion-leaving:translate-y-(--hbk-fly-y)
|
|
57
|
+
motion-reduce:transition-none;
|
|
58
|
+
}
|
|
59
|
+
@utility hbk-scale {
|
|
60
|
+
@apply [--hbk-scale:0.95] transition-[scale,opacity] duration-300 ease-out
|
|
61
|
+
starting:opacity-0 starting:scale-(--hbk-scale)
|
|
62
|
+
data-motion-leaving:opacity-0 data-motion-leaving:scale-(--hbk-scale)
|
|
63
|
+
motion-reduce:transition-none;
|
|
64
|
+
}
|
|
65
|
+
@utility hbk-blur {
|
|
66
|
+
@apply [--hbk-blur:4px] transition-[filter,opacity] duration-300
|
|
67
|
+
starting:opacity-0 starting:blur-(--hbk-blur)
|
|
68
|
+
data-motion-leaving:opacity-0 data-motion-leaving:blur-(--hbk-blur)
|
|
69
|
+
motion-reduce:transition-none;
|
|
70
|
+
}
|
data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_list.html.erb.tt
CHANGED
|
@@ -3,23 +3,28 @@
|
|
|
3
3
|
<%%# locals: (<%= list_locals_signature %>) -%>
|
|
4
4
|
<%%# The island's re-rendered fragment. Its root id is the broadcast target —
|
|
5
5
|
deliberately not the island root, which carries the stream source. %>
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
truthful as the live state moves. %>
|
|
6
|
+
<%%# Real hrefs for the <%= infinite? ? "Load-more link" : "page links" %>, carrying the current query
|
|
7
|
+
state — the channel re-renders them with url_params fresh, so the
|
|
8
|
+
fallback URLs stay truthful as the live state moves. %>
|
|
10
9
|
<%% page_url = ->(n) do
|
|
11
10
|
query = url_params.except(:page)
|
|
12
11
|
query[:page] = n unless n == 1
|
|
13
12
|
<%= index_helper(type: :path) %>(**query)
|
|
14
13
|
end %>
|
|
15
|
-
<% end -%>
|
|
16
14
|
<div id="<%= list_dom_id %>"<%= css_attr(:list) %>>
|
|
17
15
|
<% if inline_create? -%>
|
|
18
16
|
<%%# The inline create form — only the channel ever renders it (the New
|
|
19
17
|
link's action sets creating), so the first paint never carries it. %>
|
|
20
18
|
<%% if creating %>
|
|
19
|
+
<% if motion? -%>
|
|
20
|
+
<%%# data-motion: the client holds the card while it slides shut; the
|
|
21
|
+
slide itself is hbk-slide in hibiki_motion.css. %>
|
|
22
|
+
<div id="<%= row_dom_id_prefix %>_new" data-motion<%= hbk_attr("hbk-slide", token: :card) %>>
|
|
23
|
+
<div<%= hbk_attr("hbk-slide-body", token: :card_body) %>>
|
|
24
|
+
<% else -%>
|
|
21
25
|
<div id="<%= row_dom_id_prefix %>_new"<%= css_attr(:card) %>>
|
|
22
26
|
<div<%= css_attr(:card_body) %>>
|
|
27
|
+
<% end -%>
|
|
23
28
|
<%%= render "<%= partial_path(row_form_partial) %>", <%= wrapped_list(create_form_render_locals, indent: render_indent(partial_path(row_form_partial)) + 4, width: 0).lstrip %> %>
|
|
24
29
|
</div>
|
|
25
30
|
</div>
|
|
@@ -44,20 +49,25 @@
|
|
|
44
49
|
<%% end %>
|
|
45
50
|
|
|
46
51
|
<% if infinite? -%>
|
|
47
|
-
<%%# `visible` is a pseudo-event backed by an IntersectionObserver
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
<%%# `visible` is a pseudo-event backed by an IntersectionObserver: the wrapper
|
|
53
|
+
is the scroll sentinel, the link inside it is the click path and its href
|
|
54
|
+
the fallback. A sentinel must never fall back — scrolling into view on a
|
|
55
|
+
dead socket would navigate. %>
|
|
50
56
|
<%% if remaining.positive? %>
|
|
51
|
-
|
|
52
|
-
<%%= tag.button(type: "button",
|
|
57
|
+
<%%= tag.div(id: "<%= load_more_dom_id %>",
|
|
53
58
|
<% if css? -%>
|
|
54
|
-
|
|
59
|
+
class: "<%= css(:load_more_wrap) %>",
|
|
55
60
|
<% end -%>
|
|
56
|
-
|
|
57
|
-
|
|
61
|
+
**on(:go_to_page, event: :visible, with: { page: page + 1 })) do %>
|
|
62
|
+
<%%= link_to "#{page_url.(page + 1)}#<%= row_dom_id_prefix %>_#{<%= controller_file_name %>.last.id}",
|
|
63
|
+
<% if css? -%>
|
|
64
|
+
class: "<%= css(:btn_wide) %>",
|
|
65
|
+
<% end -%>
|
|
66
|
+
data: { turbo: false }.merge(on(:go_to_page, with: { page: page + 1 },
|
|
67
|
+
fallback: true)[:data]) do %>
|
|
58
68
|
Load more<span<%= css_attr(:muted_inline) %>> · <%%= remaining %> remaining</span>
|
|
59
69
|
<%% end %>
|
|
60
|
-
|
|
70
|
+
<%% end %>
|
|
61
71
|
<%% end %>
|
|
62
72
|
<% else -%>
|
|
63
73
|
<%%= render "shared/pagination", page: page, page_count: page_count, pages: pages,
|
data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_row.html.erb.tt
CHANGED
|
@@ -9,11 +9,34 @@
|
|
|
9
9
|
default. %>
|
|
10
10
|
<%%# The root id stays stable across display/edit, so idiomorph pairs the
|
|
11
11
|
subtrees instead of rebuilding them (and losing focus). %>
|
|
12
|
+
<% if motion? -%>
|
|
13
|
+
<%%# data-motion="own": the row slides out when its own Destroy removes it;
|
|
14
|
+
rows that page or filter away go at once. The wrapper closes the gap
|
|
15
|
+
while the card inside slides left (hbk-slide-x, hibiki_motion.css). %>
|
|
16
|
+
<div id="<%= row_dom_id_prefix %>_<%%= <%= singular_name %>.id %>" data-motion="own"<%= hbk_attr("hbk-slide-x") %>>
|
|
17
|
+
<div<%= hbk_attr("hbk-slide-x-body", token: :row_card) %>>
|
|
18
|
+
<% else -%>
|
|
12
19
|
<div id="<%= row_dom_id_prefix %>_<%%= <%= singular_name %>.id %>"<%= css_attr(:card) %>>
|
|
13
20
|
<div<%= css_attr(:card_body) %>>
|
|
21
|
+
<% end -%>
|
|
14
22
|
<%% if editing %>
|
|
23
|
+
<% if motion? -%>
|
|
24
|
+
<%%# The edit and display wrappers carry distinct ids so idiomorph swaps
|
|
25
|
+
them instead of morphing one into the other — starting: fires only
|
|
26
|
+
on a real insertion. The client holds this one while it slides shut. %>
|
|
27
|
+
<div id="<%= row_dom_id_prefix %>_<%%= <%= singular_name %>.id %>_edit" data-motion<%= hbk_attr("hbk-slide") %>>
|
|
28
|
+
<div<%= hbk_attr("hbk-slide-body") %>>
|
|
29
|
+
<%%= render "<%= partial_path(row_form_partial) %>", <%= wrapped_list(row_form_render_locals, indent: render_indent(partial_path(row_form_partial)) + 4).lstrip %> %>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
<% else -%>
|
|
15
33
|
<%%= render "<%= partial_path(row_form_partial) %>", <%= wrapped_list(row_form_render_locals, indent: render_indent(partial_path(row_form_partial))).lstrip %> %>
|
|
34
|
+
<% end -%>
|
|
16
35
|
<%% else %>
|
|
36
|
+
<% if motion? -%>
|
|
37
|
+
<%%# contents: no box of its own, so the card's layout is unchanged. %>
|
|
38
|
+
<div id="<%= row_dom_id_prefix %>_<%%= <%= singular_name %>.id %>_display"<%= css_attr(:contents) %>>
|
|
39
|
+
<% end -%>
|
|
17
40
|
<% schema.columns.each do |column| -%>
|
|
18
41
|
<div>
|
|
19
42
|
<strong><%= column.human_name %>:</strong>
|
|
@@ -53,6 +76,9 @@
|
|
|
53
76
|
confirm: "Are you sure?", fallback: true)[:data]) } %>
|
|
54
77
|
</div>
|
|
55
78
|
<%% end %>
|
|
79
|
+
<% if motion? -%>
|
|
80
|
+
</div>
|
|
81
|
+
<% end -%>
|
|
56
82
|
<%% end %>
|
|
57
83
|
</div>
|
|
58
84
|
</div>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hibiki
|
|
4
|
+
module Rails
|
|
5
|
+
module Generators
|
|
6
|
+
# Getting the motion module onto the page. The views carry the marks and
|
|
7
|
+
# the class names, the stylesheet carries the rules, and this is the
|
|
8
|
+
# third leg: the client module that holds a render while a marked
|
|
9
|
+
# element's transition runs. Without it everything still works — rows
|
|
10
|
+
# pop instead of sliding — which is exactly the kind of silent gap a
|
|
11
|
+
# notice exists for.
|
|
12
|
+
#
|
|
13
|
+
# One import line, in the file both worlds have: importmap apps resolve
|
|
14
|
+
# it through the engine's pin, bundler apps through the npm package.
|
|
15
|
+
module ScaffoldMotion
|
|
16
|
+
APPLICATION_JS = "app/javascript/application.js"
|
|
17
|
+
MOTION_IMPORT = %(import "hibiki-rails/motion"\n)
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def wire_motion_module
|
|
22
|
+
return unless motion?
|
|
23
|
+
|
|
24
|
+
@motion_wiring =
|
|
25
|
+
if wired?(APPLICATION_JS, MOTION_IMPORT.strip)
|
|
26
|
+
:already
|
|
27
|
+
elsif exists?(APPLICATION_JS)
|
|
28
|
+
append_to_file APPLICATION_JS, MOTION_IMPORT
|
|
29
|
+
:import
|
|
30
|
+
else
|
|
31
|
+
:manual
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def motion_notice
|
|
36
|
+
case @motion_wiring
|
|
37
|
+
when :import
|
|
38
|
+
say_status :motion, "#{APPLICATION_JS} now imports hibiki-rails/motion, the client module " \
|
|
39
|
+
"that lets a marked element finish its transition before a render " \
|
|
40
|
+
"removes it", :blue
|
|
41
|
+
when :manual
|
|
42
|
+
say_status :motion, "#{APPLICATION_JS} not found, so nothing will slide. Import the motion " \
|
|
43
|
+
"module from your JavaScript entry point: #{MOTION_IMPORT.strip}", :yellow
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -3,20 +3,44 @@
|
|
|
3
3
|
module Hibiki
|
|
4
4
|
module Rails
|
|
5
5
|
module Generators
|
|
6
|
-
# Emitting the
|
|
6
|
+
# Emitting the app-wide stylesheets, and getting them onto the page.
|
|
7
7
|
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
8
|
+
# Two of them: the transport stylesheet (the client's loading and
|
|
9
|
+
# connection state) and, when motion is on, the motion stylesheet (the
|
|
10
|
+
# classes the views name). Both key on attributes the client stamps and
|
|
11
|
+
# know nothing about any model, so each is ONE file per app rather than
|
|
12
|
+
# one per resource — which is also why every write here is idempotent:
|
|
13
|
+
# scaffolding a second resource finds it already present and leaves it,
|
|
14
|
+
# and the wiring line it needs is added at most once.
|
|
13
15
|
#
|
|
14
|
-
# Getting
|
|
15
|
-
# stylesheet" is not one thing. Three app shapes cover everything
|
|
16
|
-
# generators have been run against, and the fourth branch is a
|
|
17
|
-
# instruction rather than a guess.
|
|
16
|
+
# Getting a file onto the page is the part with branches, because "the
|
|
17
|
+
# main stylesheet" is not one thing. Three app shapes cover everything
|
|
18
|
+
# the generators have been run against, and the fourth branch is a
|
|
19
|
+
# printed instruction rather than a guess.
|
|
18
20
|
module ScaffoldTransportStylesheet
|
|
19
|
-
|
|
21
|
+
DIR = "app/assets/stylesheets"
|
|
22
|
+
|
|
23
|
+
# What each file is FOR, in the notice's words: what it does once
|
|
24
|
+
# wired, and what stays broken while it is not. The busy sheet is
|
|
25
|
+
# always installed; the motion sheet only under motion?.
|
|
26
|
+
STYLESHEETS = {
|
|
27
|
+
busy: {
|
|
28
|
+
file: "hibiki_busy.css",
|
|
29
|
+
wired: "It styles the client's loading and connection state, and is shared by every " \
|
|
30
|
+
"generated resource",
|
|
31
|
+
unwired: "the loading and connection state will be invisible"
|
|
32
|
+
},
|
|
33
|
+
motion: {
|
|
34
|
+
file: "hibiki_motion.css",
|
|
35
|
+
wired: "It defines the motion classes the views name (hbk-slide, hbk-slide-x, hbk-fade, " \
|
|
36
|
+
"…); duration and easing are tuned there",
|
|
37
|
+
unwired: "nothing will slide"
|
|
38
|
+
}
|
|
39
|
+
}.freeze
|
|
40
|
+
|
|
41
|
+
# The busy sheet's names, kept as constants: the specs and the docs
|
|
42
|
+
# refer to them.
|
|
43
|
+
STYLESHEET = "#{DIR}/#{STYLESHEETS.dig(:busy, :file)}".freeze
|
|
20
44
|
LOGICAL_NAME = "hibiki_busy"
|
|
21
45
|
|
|
22
46
|
# The LEADING ./ is not decoration. Tailwind 4's CLI resolves imports
|
|
@@ -26,12 +50,13 @@ module Hibiki
|
|
|
26
50
|
IMPORT_LINE = %(@import "./hibiki_busy.css";)
|
|
27
51
|
|
|
28
52
|
# The entry stylesheet a bundler compiles, in the order the two
|
|
29
|
-
# conventions are checked
|
|
30
|
-
#
|
|
31
|
-
#
|
|
53
|
+
# conventions are checked, each with the path an import there needs.
|
|
54
|
+
# cssbundling-rails keeps it beside the other stylesheets;
|
|
55
|
+
# tailwindcss-rails gives it its own directory, so the import has to
|
|
56
|
+
# climb out of it.
|
|
32
57
|
ENTRIES = {
|
|
33
|
-
"app/assets/stylesheets/application.tailwind.css" =>
|
|
34
|
-
"app/assets/tailwind/application.css" =>
|
|
58
|
+
"app/assets/stylesheets/application.tailwind.css" => "./",
|
|
59
|
+
"app/assets/tailwind/application.css" => "../stylesheets/"
|
|
35
60
|
}.freeze
|
|
36
61
|
|
|
37
62
|
LAYOUT = "app/views/layouts/application.html.erb"
|
|
@@ -43,8 +68,10 @@ module Hibiki
|
|
|
43
68
|
|
|
44
69
|
# Anchor for the fallback, and deliberately narrow: it matches the line
|
|
45
70
|
# a stock layout carries so the new tag lands directly after it, in the
|
|
46
|
-
# <head>, where a stylesheet belongs.
|
|
47
|
-
|
|
71
|
+
# <head>, where a stylesheet belongs. Never one of OUR tags: Thor's
|
|
72
|
+
# insert_into_file lands after every match, and the second sheet must
|
|
73
|
+
# not land twice.
|
|
74
|
+
SINGLE_LINK = /^.*stylesheet_link_tag\s+(?!"hibiki_)["':].*$\n/
|
|
48
75
|
|
|
49
76
|
LINK_TAG = %( <%= stylesheet_link_tag "hibiki_busy", "data-turbo-track": "reload" %>\n)
|
|
50
77
|
|
|
@@ -54,39 +81,48 @@ module Hibiki
|
|
|
54
81
|
# always opens with its own import, which ours must follow anyway. An
|
|
55
82
|
# entry someone rewrote without that line just gets the import
|
|
56
83
|
# appended; assume they will place it themselves. (Order never protects
|
|
57
|
-
# the rules from the utilities — being unlayered does, see the
|
|
84
|
+
# the rules from the utilities — being unlayered does, see the busy
|
|
58
85
|
# stylesheet's own header.)
|
|
59
86
|
TAILWIND_IMPORT = %(@import "tailwindcss";\n)
|
|
60
87
|
|
|
61
88
|
private
|
|
62
89
|
|
|
63
90
|
def create_transport_stylesheet
|
|
64
|
-
|
|
65
|
-
|
|
91
|
+
@stylesheet_wiring = {}
|
|
92
|
+
install_stylesheet(:busy)
|
|
93
|
+
install_stylesheet(:motion) if motion?
|
|
66
94
|
end
|
|
67
95
|
|
|
68
96
|
# Records what happened for the post-install notice, which is the only
|
|
69
97
|
# place the user learns which branch they landed on.
|
|
70
|
-
def
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
98
|
+
def install_stylesheet(key)
|
|
99
|
+
sheet = STYLESHEETS.fetch(key)
|
|
100
|
+
template "#{sheet[:file]}.tt", stylesheet_path(sheet)
|
|
101
|
+
@stylesheet_wiring[key] = wire_stylesheet(sheet)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def wire_stylesheet(sheet)
|
|
105
|
+
if (entry = ENTRIES.keys.find { exists?(it) })
|
|
106
|
+
import_into_entry(entry, sheet)
|
|
107
|
+
elsif wired?(LAYOUT, BULK_LINK)
|
|
108
|
+
:bulk
|
|
109
|
+
else
|
|
110
|
+
link_from_layout(sheet)
|
|
111
|
+
end
|
|
79
112
|
end
|
|
80
113
|
|
|
81
114
|
# The :already probe uses the entry's OWN line, not IMPORT_LINE — the
|
|
82
115
|
# tailwindcss-rails entry imports "../stylesheets/hibiki_busy.css", and
|
|
83
116
|
# probing for the "./" spelling there re-appended on every re-run.
|
|
84
|
-
|
|
85
|
-
|
|
117
|
+
# A later sheet lands below the one before it, so the entry reads in
|
|
118
|
+
# install order.
|
|
119
|
+
def import_into_entry(entry, sheet)
|
|
120
|
+
line = import_line(entry, sheet)
|
|
86
121
|
return :already if wired?(entry, line)
|
|
87
122
|
|
|
88
|
-
|
|
89
|
-
|
|
123
|
+
anchor = [previous_import(entry, sheet), TAILWIND_IMPORT].compact.find { wired?(entry, it) }
|
|
124
|
+
if anchor
|
|
125
|
+
inject_into_file entry, "#{line}\n", after: anchor
|
|
90
126
|
else
|
|
91
127
|
append_to_file entry, "#{line}\n"
|
|
92
128
|
end
|
|
@@ -97,27 +133,44 @@ module Hibiki
|
|
|
97
133
|
# compiler rewrites url(...) — never @import. So an @import appended to
|
|
98
134
|
# a plain application.css resolves to an undigested path and 404s
|
|
99
135
|
# silently, which is why this branch edits the layout instead.
|
|
100
|
-
def link_from_layout
|
|
101
|
-
return :already if wired?(LAYOUT,
|
|
136
|
+
def link_from_layout(sheet)
|
|
137
|
+
return :already if wired?(LAYOUT, logical_name(sheet))
|
|
102
138
|
return :manual unless exists?(LAYOUT) && wired?(LAYOUT, SINGLE_LINK)
|
|
103
139
|
|
|
104
|
-
inject_into_file LAYOUT,
|
|
140
|
+
inject_into_file LAYOUT, link_tag(sheet), after: SINGLE_LINK
|
|
105
141
|
:layout
|
|
106
142
|
end
|
|
107
143
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
144
|
+
def stylesheet_path(sheet) = "#{DIR}/#{sheet[:file]}"
|
|
145
|
+
def logical_name(sheet) = File.basename(sheet[:file], ".css")
|
|
146
|
+
def import_line(entry, sheet) = %(@import "#{ENTRIES.fetch(entry)}#{sheet[:file]}";)
|
|
147
|
+
def link_tag(sheet) = %( <%= stylesheet_link_tag "#{logical_name(sheet)}", "data-turbo-track": "reload" %>\n)
|
|
148
|
+
|
|
149
|
+
# The import line of the sheet installed before this one, or nil.
|
|
150
|
+
def previous_import(entry, sheet)
|
|
151
|
+
keys = STYLESHEETS.keys
|
|
152
|
+
index = keys.index { STYLESHEETS[it] == sheet }
|
|
153
|
+
return nil unless index&.positive?
|
|
154
|
+
|
|
155
|
+
"#{import_line(entry, STYLESHEETS[keys[index - 1]])}\n"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Which way each sheet reached the page. Two of the four say what was
|
|
159
|
+
# EDITED, because a file this generator touched outside app/{channels,
|
|
160
|
+
# models,forms,views,controllers} should never be a surprise; :bulk
|
|
161
|
+
# and :already edited nothing and say nothing.
|
|
112
162
|
#
|
|
113
163
|
# :manual is the one that fails silently when ignored — the stylesheet
|
|
114
164
|
# is on disk, nothing links it, every indicator is invisible, and
|
|
115
165
|
# nothing raises.
|
|
116
166
|
def stylesheet_notice
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
167
|
+
@stylesheet_wiring.each do |key, wiring|
|
|
168
|
+
sheet = STYLESHEETS.fetch(key)
|
|
169
|
+
case wiring
|
|
170
|
+
when :import then say_stylesheet_wired(sheet, "imported from your entry stylesheet")
|
|
171
|
+
when :layout then say_stylesheet_wired(sheet, "linked from #{LAYOUT}")
|
|
172
|
+
when :manual then say_stylesheet_manual(sheet)
|
|
173
|
+
end
|
|
121
174
|
end
|
|
122
175
|
|
|
123
176
|
stale_busy_partial_notice
|
|
@@ -136,16 +189,16 @@ module Hibiki
|
|
|
136
189
|
"asset — nothing renders it now, you can safely delete it", :yellow
|
|
137
190
|
end
|
|
138
191
|
|
|
139
|
-
def say_stylesheet_wired(how)
|
|
140
|
-
say_status :css, "#{
|
|
141
|
-
"connection state, and is shared by every generated resource", :blue
|
|
192
|
+
def say_stylesheet_wired(sheet, how)
|
|
193
|
+
say_status :css, "#{stylesheet_path(sheet)} was created and #{how}. #{sheet[:wired]}", :blue
|
|
142
194
|
end
|
|
143
195
|
|
|
144
|
-
def say_stylesheet_manual
|
|
145
|
-
say_status :css, "#{
|
|
146
|
-
"
|
|
147
|
-
"#{
|
|
148
|
-
", or import from your entry stylesheet:
|
|
196
|
+
def say_stylesheet_manual(sheet)
|
|
197
|
+
say_status :css, "#{stylesheet_path(sheet)} was created but nothing links it, so " \
|
|
198
|
+
"#{sheet[:unwired]}. Add it to your layout —\n " \
|
|
199
|
+
"#{link_tag(sheet).strip}\n " \
|
|
200
|
+
", or import from your entry stylesheet: " \
|
|
201
|
+
"#{import_line(ENTRIES.keys.first, sheet)}", :yellow
|
|
149
202
|
end
|
|
150
203
|
end
|
|
151
204
|
end
|
data/lib/hibiki/rails/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hibiki_rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.15.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- planetaska
|
|
@@ -76,6 +76,7 @@ extra_rdoc_files: []
|
|
|
76
76
|
files:
|
|
77
77
|
- CHANGELOG.md
|
|
78
78
|
- README.md
|
|
79
|
+
- app/assets/javascripts/hibiki-motion.js
|
|
79
80
|
- app/assets/javascripts/hibiki.js
|
|
80
81
|
- config/importmap.rb
|
|
81
82
|
- lib/generators/hibiki/rails/css_variant.rb
|
|
@@ -136,6 +137,7 @@ files:
|
|
|
136
137
|
- lib/generators/hibiki/rails/scaffold_controller/templates/shared/controller.rb.tt
|
|
137
138
|
- lib/generators/hibiki/rails/scaffold_controller/templates/shared/form.rb.tt
|
|
138
139
|
- lib/generators/hibiki/rails/scaffold_controller/templates/shared/hibiki_busy.css.tt
|
|
140
|
+
- lib/generators/hibiki/rails/scaffold_controller/templates/shared/hibiki_motion.css.tt
|
|
139
141
|
- lib/generators/hibiki/rails/scaffold_controller/templates/shared/member_channel.rb.tt
|
|
140
142
|
- lib/generators/hibiki/rails/scaffold_controller/templates/shared/query.rb.tt
|
|
141
143
|
- lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_controls.html.erb.tt
|
|
@@ -152,6 +154,7 @@ files:
|
|
|
152
154
|
- lib/generators/hibiki/rails/scaffold_controller/templates/tailwind/views/_pagination.html.erb.tt
|
|
153
155
|
- lib/generators/hibiki/rails/scaffold_helpers.rb
|
|
154
156
|
- lib/generators/hibiki/rails/scaffold_model_injection.rb
|
|
157
|
+
- lib/generators/hibiki/rails/scaffold_motion.rb
|
|
155
158
|
- lib/generators/hibiki/rails/scaffold_parent_injection.rb
|
|
156
159
|
- lib/generators/hibiki/rails/scaffold_parent_notices.rb
|
|
157
160
|
- lib/generators/hibiki/rails/scaffold_phlex_helpers.rb
|