hibiki_rails 0.14.0 → 0.16.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 +102 -0
- data/app/assets/javascripts/hibiki-motion.js +164 -0
- data/app/assets/javascripts/hibiki.js +134 -23
- 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 +7 -0
- 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 +7 -0
- 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/dom_props.rb +33 -0
- data/lib/hibiki/rails/helpers.rb +30 -0
- data/lib/hibiki/rails/version.rb +1 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d7bfeee9d4e44c3404ac6582548e0bdb46ae139cfa26eec2c71b7d8b95c40c77
|
|
4
|
+
data.tar.gz: ab0505b42c8450e0a8014f94c380ed2f3e995448a9cf8cfc8ed16f4eb450d524
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6c032ed168913d5c4cc0a56afdf8b6a58cd89ad61581acfda17fc6e2a8030f0ebe4a81f35e0f4ec58691b79df2e6831be4bfa75f4d850db2ff334d9504a30bbb
|
|
7
|
+
data.tar.gz: 79144e77c309815099be858e605ddad09c6a655ebc2b66f3e20c68fdfe6cf0dceca295bc418304ea6fb0865dfda732a9a4704018029fd8f52a39f21148e15532
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,108 @@ 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.16.0 — 2026-10-07
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
**`props`: DOM properties from the view.** Some state of a control has no
|
|
12
|
+
HTML attribute, so no re-rendered fragment could carry it: a checkbox's
|
|
13
|
+
`indeterminate`, a container's scroll position. A page that needed one
|
|
14
|
+
brought JavaScript of its own. The new helper writes the wanted properties
|
|
15
|
+
beside the element's other attributes, and the client assigns them when the
|
|
16
|
+
island connects and after every render, on both transports.
|
|
17
|
+
|
|
18
|
+
```erb
|
|
19
|
+
<%= check_box_tag "all", "1", all_selected,
|
|
20
|
+
**on(:select_all, event: :change)
|
|
21
|
+
.deep_merge(props(indeterminate: some_selected)) %>
|
|
22
|
+
<%= tag.div class: "list", **props(scroll_top: 0, key: page) do %>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
- `indeterminate:` is assigned after every render, so the server's value wins
|
|
26
|
+
over a click (the browser clears the property when the box is clicked).
|
|
27
|
+
- `scroll_top:` and `scroll_left:` are assigned to a new element, and again
|
|
28
|
+
only when the helper's output changes, so the visitor's own scrolling
|
|
29
|
+
survives unrelated renders. `key:` takes any JSON value and exists to change
|
|
30
|
+
the output: `scroll_top: 0, key: page` scrolls back to the top on each page
|
|
31
|
+
change, though the `0` never changes.
|
|
32
|
+
- The list is an allowlist kept on both sides: an unknown name raises in Ruby
|
|
33
|
+
and is ignored by the client, so a fragment cannot reach `innerHTML` or an
|
|
34
|
+
event handler.
|
|
35
|
+
- `props` returns a `{ data: }` hash like `on`. Two splats of the same key do
|
|
36
|
+
not merge, so combine them with `deep_merge` (or Phlex's `mix`).
|
|
37
|
+
- The element must be inside an island. This is a client change: the scan that
|
|
38
|
+
re-observes `visible` sentinels after a render now assigns properties in the
|
|
39
|
+
same walk.
|
|
40
|
+
|
|
41
|
+
Not covered, by design: properties that already have an attribute (`value`,
|
|
42
|
+
`checked`, `disabled`, `open` on `<details>`) ride the fragment as before.
|
|
43
|
+
Modal dialogs, focus and media playback are method calls, not properties, and
|
|
44
|
+
are left for a later release.
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- A `visible` sentinel inside a page that a refresh stream
|
|
49
|
+
(`broadcast_refresh_effect`) morphed was not re-observed: the page morph
|
|
50
|
+
runs after the stream's own render returns. The island now also rescans on
|
|
51
|
+
`turbo:render`.
|
|
52
|
+
|
|
53
|
+
## 0.15.0 — 2026-09-09
|
|
54
|
+
|
|
55
|
+
### Added
|
|
56
|
+
|
|
57
|
+
**Motion for generated pages, and the `hibiki-rails/motion` module.** A
|
|
58
|
+
re-rendered fragment is merged into the page in place, so a removed element
|
|
59
|
+
was gone before the browser could draw it leaving. The new opt-in module
|
|
60
|
+
holds a render while a marked element's transitions finish: mark an element
|
|
61
|
+
`data-motion` (it needs an id), give it a transition, and `import
|
|
62
|
+
"hibiki-rails/motion"` once beside your controllers. The module writes
|
|
63
|
+
`data-motion-leaving` on each marked element the incoming fragment lacks,
|
|
64
|
+
waits for its transitions, then lets the render run; it brackets an inserted
|
|
65
|
+
marked element with `data-motion-entering` so a clip can be scoped to the
|
|
66
|
+
motion. `data-motion="own"` animates the leave only when the gesture that
|
|
67
|
+
removed the element came from inside it, read from the island's own trip
|
|
68
|
+
records. Renders queue in arrival order; a hidden tab waits for nothing;
|
|
69
|
+
every wait races a one-second ceiling. Both transports hold. This is a
|
|
70
|
+
client change: the four `data-motion*` attributes are public, unlike the
|
|
71
|
+
private `data-hibiki-*` ones. Docs: [Motion].
|
|
72
|
+
|
|
73
|
+
- The scaffold emits it by default under `--css=daisyui` and `tailwind`:
|
|
74
|
+
the inline create card and the row's edit form slide open and shut
|
|
75
|
+
(`hbk-slide` / `hbk-slide-body`), and a row you destroy slides out to the
|
|
76
|
+
left before the list closes the gap (`hbk-slide-x` / `hbk-slide-x-body`,
|
|
77
|
+
marked `own`). The row's display markup gains an id-bearing `contents`
|
|
78
|
+
wrapper, because the merge pairs an anonymous `div` with the edit wrapper
|
|
79
|
+
and rewrites it in place, and `@starting-style` fires only on a real
|
|
80
|
+
insertion; under motion the row's card and card-body are one element, so
|
|
81
|
+
the add-on generators' anchors still hold.
|
|
82
|
+
- `app/assets/stylesheets/hibiki_motion.css`, once per app beside the busy
|
|
83
|
+
sheet and wired the same four ways: slide, slide-x, fade, fly, scale and
|
|
84
|
+
blur as Tailwind `@utility` rules (the utilities layer sits above daisyUI's
|
|
85
|
+
components, and a single utility on the element still wins), with duration
|
|
86
|
+
and easing tuned there. Needs Tailwind 4.1. The generator appends
|
|
87
|
+
`import "hibiki-rails/motion"` to `app/javascript/application.js` once
|
|
88
|
+
and prints a `motion` notice when it cannot.
|
|
89
|
+
- `--skip-motion` on both scaffold commands leaves every trace out.
|
|
90
|
+
`--css=none` never emits it: the transitions are Tailwind utilities, and
|
|
91
|
+
its output is byte-identical to a `--skip-motion` run.
|
|
92
|
+
- Packaging: the engine pins `hibiki-rails/motion`; the npm package exports
|
|
93
|
+
`./motion`. The module imports `"hibiki-rails"` by its bare name, which
|
|
94
|
+
the pin and the package self-reference both resolve.
|
|
95
|
+
|
|
96
|
+
### Changed
|
|
97
|
+
|
|
98
|
+
- `hibiki.js` gains three seams for the module: an exported `islandFor(element)`,
|
|
99
|
+
the island's `lastControl` (set in `trackControl`, kept past the trip's
|
|
100
|
+
settle), and a `hibiki:before-render` event dispatched from the island root
|
|
101
|
+
before a transmit swap, with `event.detail.render` replaceable the way
|
|
102
|
+
Turbo's `turbo:before-stream-render` is. `received` returns the render's
|
|
103
|
+
result: `undefined` for a synchronous swap, a promise for a held one. An ack
|
|
104
|
+
settles its trip even while a render is held.
|
|
105
|
+
- `CssVariant` gains two tokens, `row_card` and `contents`.
|
|
106
|
+
|
|
107
|
+
[Motion]: https://planetaska.github.io/hibiki/motion/
|
|
108
|
+
|
|
7
109
|
## 0.14.0 — 2026-09-07
|
|
8
110
|
|
|
9
111
|
### Changed
|
|
@@ -0,0 +1,164 @@
|
|
|
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
|
+
// No island is no evidence, not a plain mark: an `own` element outside any
|
|
48
|
+
// generic island (a ChannelController subclass over Turbo streams) never
|
|
49
|
+
// animates its leave, since the policy exists to keep rows that paged or
|
|
50
|
+
// filtered away from sliding. A plain mark is the way to always animate.
|
|
51
|
+
const ownsGesture = (island, element) => {
|
|
52
|
+
if (!island) return false
|
|
53
|
+
for (const record of island.busy.values()) {
|
|
54
|
+
if (record.control && element.contains(record.control)) return true
|
|
55
|
+
}
|
|
56
|
+
return Boolean(island.lastControl) && element.contains(island.lastControl)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const animates = (element, island) =>
|
|
60
|
+
element.getAttribute("data-motion") !== "own" || ownsGesture(island, element)
|
|
61
|
+
|
|
62
|
+
// Marked elements under `target` (and `target` itself when the action
|
|
63
|
+
// replaces it) whose id the incoming content lacks.
|
|
64
|
+
const leavingFrom = (target, includeSelf, incoming, island) => {
|
|
65
|
+
const candidates =
|
|
66
|
+
includeSelf && target.hasAttribute("data-motion") ? [target, ...marked(target)] : marked(target)
|
|
67
|
+
return candidates.filter(
|
|
68
|
+
(element) => element.id && !incoming.has(element.id) && animates(element, island)
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const outermost = (elements) =>
|
|
73
|
+
elements.filter((element) => !elements.some((other) => other !== element && other.contains(element)))
|
|
74
|
+
|
|
75
|
+
// Resolves once the element's own transitions have run. Element-only, not
|
|
76
|
+
// {subtree: true}: a spinner inside a leaving row is an infinite animation.
|
|
77
|
+
const settled = async (element) => {
|
|
78
|
+
if (document.visibilityState === "hidden") return
|
|
79
|
+
await new Promise(requestAnimationFrame)
|
|
80
|
+
await Promise.allSettled((element.getAnimations?.() ?? []).map((animation) => animation.finished))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const finished = (elements) => {
|
|
84
|
+
let timer
|
|
85
|
+
const ceiling = new Promise((resolve) => {
|
|
86
|
+
timer = setTimeout(resolve, CEILING)
|
|
87
|
+
})
|
|
88
|
+
return Promise.race([Promise.all(elements.map(settled)), ceiling]).finally(() => clearTimeout(timer))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// One chain per document. A failed render must not close it.
|
|
92
|
+
let queue = Promise.resolve()
|
|
93
|
+
const enqueue = (job) => (queue = queue.catch(() => {}).then(job))
|
|
94
|
+
|
|
95
|
+
// The hold. `plan` runs when the render's turn comes (earlier renders may
|
|
96
|
+
// have changed the DOM) and returns { leaving, islands }; `render` is what
|
|
97
|
+
// the transport would have run at once.
|
|
98
|
+
const held = (plan, render) =>
|
|
99
|
+
enqueue(async () => {
|
|
100
|
+
const { leaving: candidates, islands } = plan()
|
|
101
|
+
const leaving = outermost(candidates)
|
|
102
|
+
const before = idsOf(document)
|
|
103
|
+
for (const element of leaving) element.setAttribute("data-motion-leaving", "")
|
|
104
|
+
if (leaving.length) await finished(leaving)
|
|
105
|
+
try {
|
|
106
|
+
await render()
|
|
107
|
+
} finally {
|
|
108
|
+
// A render that kept the element after all, and the gesture consumed.
|
|
109
|
+
for (const element of leaving) {
|
|
110
|
+
if (element.isConnected) element.removeAttribute("data-motion-leaving")
|
|
111
|
+
}
|
|
112
|
+
for (const island of islands) island.lastControl = null
|
|
113
|
+
}
|
|
114
|
+
const entering = outermost(marked(document).filter((element) => element.id && !before.has(element.id)))
|
|
115
|
+
for (const element of entering) {
|
|
116
|
+
element.setAttribute("data-motion-entering", "")
|
|
117
|
+
finished([element]).then(() => element.removeAttribute("data-motion-entering"))
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
// Turbo streams. Registered at import, so this wrapper sits INSIDE the
|
|
122
|
+
// islands' own render wrappers (theirs count the paint) and the hold ends
|
|
123
|
+
// before they count it. The gate keeps unmarked pages at two querySelectors;
|
|
124
|
+
// a hold is pending only while a marked element is still in the document,
|
|
125
|
+
// so any render arriving during one sees marks and queues behind it.
|
|
126
|
+
document.addEventListener("turbo:before-stream-render", (event) => {
|
|
127
|
+
const stream = event.detail.newStream
|
|
128
|
+
if (!stream?.templateElement) return
|
|
129
|
+
const content = stream.templateElement.content
|
|
130
|
+
if (!hasMarks(document) && !hasMarks(content)) return
|
|
131
|
+
|
|
132
|
+
const render = event.detail.render
|
|
133
|
+
event.detail.render = (streamElement) =>
|
|
134
|
+
held(() => {
|
|
135
|
+
const plan = { leaving: [], islands: new Set() }
|
|
136
|
+
// A refresh stream has no target: targetElements throws.
|
|
137
|
+
if (stream.action === "refresh") return plan
|
|
138
|
+
const incoming = idsOf(content)
|
|
139
|
+
for (const target of stream.targetElements) {
|
|
140
|
+
const island = islandFor(target)
|
|
141
|
+
if (island) plan.islands.add(island)
|
|
142
|
+
if (REMOVING.has(stream.action)) {
|
|
143
|
+
plan.leaving.push(...leavingFrom(target, stream.action !== "update", incoming, island))
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return plan
|
|
147
|
+
}, () => render(streamElement))
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
// The transmit transport: the client's own swap, by root id. A root id
|
|
151
|
+
// always survives a by-id swap, so only descendants can leave.
|
|
152
|
+
document.addEventListener("hibiki:before-render", (event) => {
|
|
153
|
+
const { island, content, render } = event.detail
|
|
154
|
+
if (!hasMarks(document) && !hasMarks(content)) return
|
|
155
|
+
|
|
156
|
+
event.detail.render = () =>
|
|
157
|
+
held(() => ({
|
|
158
|
+
islands: new Set([island]),
|
|
159
|
+
leaving: [...content.children].flatMap((fragment) => {
|
|
160
|
+
const target = document.getElementById(fragment.id)
|
|
161
|
+
return target ? leavingFrom(target, false, idsOf(fragment), island) : []
|
|
162
|
+
})
|
|
163
|
+
}), render)
|
|
164
|
+
})
|
|
@@ -57,6 +57,10 @@
|
|
|
57
57
|
// browser does what the markup says
|
|
58
58
|
// value sites data-hibiki-value="<name>" reactive-value placeholder;
|
|
59
59
|
// the server's transmit_value message updates every match
|
|
60
|
+
// prop sites data-hibiki-props='{"indeterminate":true}' DOM properties
|
|
61
|
+
// that have no attribute, assigned on connect and after each
|
|
62
|
+
// render; only the names in PROPS below, an optional "key"
|
|
63
|
+
// beside them existing to change the text
|
|
60
64
|
//
|
|
61
65
|
// The client writes the protocol's other half at runtime — read-only to
|
|
62
66
|
// app code, addressed to app CSS:
|
|
@@ -71,6 +75,14 @@
|
|
|
71
75
|
// and per-button feedback needs no server state. Content is stale during
|
|
72
76
|
// a round trip, never absent.
|
|
73
77
|
//
|
|
78
|
+
// Motion is the one PUBLIC data-* contract, and it lives in the opt-in
|
|
79
|
+
// module `hibiki-rails/motion`: an app marks an element `data-motion` (or
|
|
80
|
+
// `data-motion="own"`), the module stamps `data-motion-leaving` /
|
|
81
|
+
// `data-motion-entering` around a swap and holds the render while a
|
|
82
|
+
// leaving element's transitions run. Its header carries the rules; this
|
|
83
|
+
// file only gives it two seams — `islandFor` and the `hibiki:before-render`
|
|
84
|
+
// event the transmit swap dispatches (the same shape Turbo gives streams).
|
|
85
|
+
//
|
|
74
86
|
// The left side of `->` is a hibiki event name: click, change, input,
|
|
75
87
|
// and submit are delegated DOM listeners; `visible` is a pseudo-event
|
|
76
88
|
// backed by an IntersectionObserver (the element entering the viewport).
|
|
@@ -131,6 +143,20 @@ let consumer
|
|
|
131
143
|
// island pins nothing even if disconnect never ran.
|
|
132
144
|
const islands = new WeakMap()
|
|
133
145
|
|
|
146
|
+
// The generic island CONTAINING element, or undefined. The walk performOn
|
|
147
|
+
// uses, exported for the motion module's `own` policy — which reads the
|
|
148
|
+
// island's trip records, so a ChannelController subclass (not in the map)
|
|
149
|
+
// yields no records: its `own` marks never animate a leave a Turbo stream
|
|
150
|
+
// removes. (The transmit swap's event carries the subclass itself, so
|
|
151
|
+
// `own` works there once the subclass tracks its controls.)
|
|
152
|
+
export function islandFor(element) {
|
|
153
|
+
for (let node = element; node; node = node.parentElement) {
|
|
154
|
+
const island = islands.get(node)
|
|
155
|
+
if (island) return island
|
|
156
|
+
}
|
|
157
|
+
return undefined
|
|
158
|
+
}
|
|
159
|
+
|
|
134
160
|
// camelCase Stimulus method name → snake_case Ruby channel action.
|
|
135
161
|
const underscore = (name) => name.replace(/([A-Z])/g, "_$1").toLowerCase()
|
|
136
162
|
|
|
@@ -228,6 +254,7 @@ export class ChannelController extends Controller {
|
|
|
228
254
|
this.seq = 0
|
|
229
255
|
this.renders = 0
|
|
230
256
|
this.busy = new Map()
|
|
257
|
+
this.lastControl = null
|
|
231
258
|
this.queued = []
|
|
232
259
|
this.setState("connecting")
|
|
233
260
|
}
|
|
@@ -348,7 +375,11 @@ export class ChannelController extends Controller {
|
|
|
348
375
|
// A depth counter, not a boolean: typing while a page loads is one island
|
|
349
376
|
// with two actions outstanding. No requestAnimationFrame anywhere — it
|
|
350
377
|
// never fires in a background tab, which is exactly when a stuck
|
|
351
|
-
// indicator goes unnoticed.
|
|
378
|
+
// indicator goes unnoticed. (The motion module is the one place a frame
|
|
379
|
+
// is waited for, and it waits for nothing in a hidden document.) An ack
|
|
380
|
+
// settles a trip even while that module is holding the render: the flag
|
|
381
|
+
// means the server has not answered, and a hold is motion after the
|
|
382
|
+
// answer.
|
|
352
383
|
|
|
353
384
|
beginBusy(seq) {
|
|
354
385
|
const record = {
|
|
@@ -373,6 +404,13 @@ export class ChannelController extends Controller {
|
|
|
373
404
|
// has none); controls outside it — the search field — need the removal
|
|
374
405
|
// in settle().
|
|
375
406
|
trackControl(seq, control) {
|
|
407
|
+
// The most recent gesture, kept past its trip's settle for the motion
|
|
408
|
+
// module's `own` policy (a render can land after the ack's grace). Set
|
|
409
|
+
// before the record check: a send that died on the socket was still a
|
|
410
|
+
// gesture. A ChannelController subclass wanting `own` calls this itself —
|
|
411
|
+
// which serves the transmit swap only: a Turbo stream reaches the
|
|
412
|
+
// records through islandFor, and that walk lists generic islands alone.
|
|
413
|
+
this.lastControl = control
|
|
376
414
|
const record = this.busy.get(seq)
|
|
377
415
|
if (!record) return
|
|
378
416
|
record.control = control
|
|
@@ -453,7 +491,13 @@ export class ChannelController extends Controller {
|
|
|
453
491
|
//
|
|
454
492
|
// { url } — mirror graph state into the address bar (transmit_url).
|
|
455
493
|
//
|
|
456
|
-
// { html } — a fragment: swap it in by its root id.
|
|
494
|
+
// { html } — a fragment: swap it in by its root id. The swap is holdable
|
|
495
|
+
// the way Turbo's is: `hibiki:before-render` bubbles from the island
|
|
496
|
+
// first (detail { island, content, render }), and a listener may replace
|
|
497
|
+
// detail.render with one that waits — the motion module does, to let a
|
|
498
|
+
// leaving element's transition finish. Returns whatever that render
|
|
499
|
+
// returns: undefined when the swap ran synchronously, a promise when it
|
|
500
|
+
// was held, which is what a subclass reading the DOM afterwards awaits.
|
|
457
501
|
//
|
|
458
502
|
// Anything else is not ours to interpret. Subclasses may override, but
|
|
459
503
|
// should call super (or handle `value`) to keep reactive values live.
|
|
@@ -469,9 +513,18 @@ export class ChannelController extends Controller {
|
|
|
469
513
|
if (!html) return
|
|
470
514
|
const template = document.createElement("template")
|
|
471
515
|
template.innerHTML = html
|
|
472
|
-
|
|
473
|
-
|
|
516
|
+
const fragments = [...template.content.children]
|
|
517
|
+
const render = () => {
|
|
518
|
+
for (const fragment of fragments) {
|
|
519
|
+
document.getElementById(fragment.id)?.replaceWith(fragment)
|
|
520
|
+
}
|
|
474
521
|
}
|
|
522
|
+
const event = new CustomEvent("hibiki:before-render", {
|
|
523
|
+
bubbles: true,
|
|
524
|
+
detail: { island: this, content: template.content, render }
|
|
525
|
+
})
|
|
526
|
+
this.element.dispatchEvent(event)
|
|
527
|
+
return event.detail.render()
|
|
475
528
|
}
|
|
476
529
|
|
|
477
530
|
// replaceState, never pushState: the URL is a mirror of graph state, not
|
|
@@ -533,6 +586,40 @@ export class ChannelController extends Controller {
|
|
|
533
586
|
}
|
|
534
587
|
}
|
|
535
588
|
|
|
589
|
+
// The DOM properties a fragment may set (Helpers#props), and when. A
|
|
590
|
+
// property has no attribute, so no swap or morph carries it: the client
|
|
591
|
+
// assigns it after the fact. The table is the allowlist — a fragment must
|
|
592
|
+
// never reach innerHTML or onclick — and a value of the wrong type is
|
|
593
|
+
// skipped. `always` is re-assigned after every render (the browser clears
|
|
594
|
+
// indeterminate on click; the server's value comes back). `changed` is
|
|
595
|
+
// assigned only to a new element or when the attribute's text differs from
|
|
596
|
+
// the text last applied, so the visitor's own scrolling survives unrelated
|
|
597
|
+
// renders; the view's `key:` is how it says "again".
|
|
598
|
+
const PROPS = {
|
|
599
|
+
indeterminate: ["boolean", "always"],
|
|
600
|
+
scrollTop: ["number", "changed"],
|
|
601
|
+
scrollLeft: ["number", "changed"]
|
|
602
|
+
}
|
|
603
|
+
const appliedProps = new WeakMap()
|
|
604
|
+
|
|
605
|
+
function applyProps(element) {
|
|
606
|
+
const text = element.getAttribute("data-hibiki-props")
|
|
607
|
+
let wanted
|
|
608
|
+
try {
|
|
609
|
+
wanted = JSON.parse(text)
|
|
610
|
+
} catch {
|
|
611
|
+
return
|
|
612
|
+
}
|
|
613
|
+
if (!wanted || typeof wanted !== "object") return
|
|
614
|
+
const fresh = appliedProps.get(element) !== text
|
|
615
|
+
for (const [name, [type, policy]] of Object.entries(PROPS)) {
|
|
616
|
+
const value = wanted[name]
|
|
617
|
+
if (typeof value !== type || (type === "number" && !Number.isFinite(value))) continue
|
|
618
|
+
if (policy === "always" || fresh) element[name] = value
|
|
619
|
+
}
|
|
620
|
+
appliedProps.set(element, text)
|
|
621
|
+
}
|
|
622
|
+
|
|
536
623
|
// The generic controller: adds the data-hibiki-* wire protocol on top of
|
|
537
624
|
// the base's plumbing.
|
|
538
625
|
export default class HibikiController extends ChannelController {
|
|
@@ -562,6 +649,12 @@ export default class HibikiController extends ChannelController {
|
|
|
562
649
|
// rather than from the middle of it.
|
|
563
650
|
this.prepareTransport()
|
|
564
651
|
|
|
652
|
+
// First paint, so before the subscription: a property is right while
|
|
653
|
+
// the island is still connecting, and stays right if it never does.
|
|
654
|
+
for (const site of this.element.querySelectorAll("[data-hibiki-props]")) {
|
|
655
|
+
applyProps(site)
|
|
656
|
+
}
|
|
657
|
+
|
|
565
658
|
// Root-scoped delegation (bound to the island, not document): controls
|
|
566
659
|
// inside server-replaced fragments keep working with no rebinding.
|
|
567
660
|
// Set up synchronously so disconnect can always tear them down.
|
|
@@ -594,7 +687,7 @@ export default class HibikiController extends ChannelController {
|
|
|
594
687
|
}
|
|
595
688
|
})
|
|
596
689
|
|
|
597
|
-
// Re-scan at the
|
|
690
|
+
// Re-scan at the points a fragment can be swapped under us, rather
|
|
598
691
|
// than blanket-observing the document: a MutationObserver over the page
|
|
599
692
|
// is a real per-mutation cost paid by every app on it.
|
|
600
693
|
this.streamRender = (event) => {
|
|
@@ -607,14 +700,19 @@ export default class HibikiController extends ChannelController {
|
|
|
607
700
|
// — which at worst settles an already-acked trip a few ms early,
|
|
608
701
|
// never one that has not been acked at all.
|
|
609
702
|
this.renders++
|
|
610
|
-
this.
|
|
703
|
+
this.scan()
|
|
611
704
|
}
|
|
612
705
|
}
|
|
613
706
|
document.addEventListener("turbo:before-stream-render", this.streamRender)
|
|
707
|
+
// A refresh stream morphs the page after its own render has returned,
|
|
708
|
+
// so the wrapper above scans too early for it; turbo:render follows
|
|
709
|
+
// the morph.
|
|
710
|
+
this.pageRender = () => this.scan()
|
|
711
|
+
document.addEventListener("turbo:render", this.pageRender)
|
|
614
712
|
|
|
615
713
|
await this.openSubscription()
|
|
616
714
|
if (this.aborted) return
|
|
617
|
-
this.
|
|
715
|
+
this.scan()
|
|
618
716
|
}
|
|
619
717
|
|
|
620
718
|
disconnect() {
|
|
@@ -623,25 +721,40 @@ export default class HibikiController extends ChannelController {
|
|
|
623
721
|
this.element.removeEventListener(type, handler)
|
|
624
722
|
}
|
|
625
723
|
document.removeEventListener("turbo:before-stream-render", this.streamRender)
|
|
724
|
+
document.removeEventListener("turbo:render", this.pageRender)
|
|
626
725
|
for (const id of this.pending) clearTimeout(id)
|
|
627
726
|
this.pending.clear()
|
|
628
727
|
this.observer.disconnect()
|
|
629
728
|
super.disconnect()
|
|
630
729
|
}
|
|
631
730
|
|
|
632
|
-
// The other swap point: hibiki's own transmit transport.
|
|
731
|
+
// The other swap point: hibiki's own transmit transport. A held swap
|
|
732
|
+
// hands back a promise; the replacement sentinel exists only after it.
|
|
633
733
|
received(data) {
|
|
634
|
-
super.received(data)
|
|
635
|
-
if (data.html)
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
//
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
734
|
+
const swapped = super.received(data)
|
|
735
|
+
if (!data.html) return
|
|
736
|
+
if (swapped?.then) swapped.then(() => this.scan())
|
|
737
|
+
else this.scan()
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// What a render leaves for the client to finish, in one walk of the
|
|
741
|
+
// island: assign every prop site's properties, and observe every
|
|
742
|
+
// `visible->` sentinel this island owns. observe() is a no-op for an
|
|
743
|
+
// element already being observed, so re-scanning is cheap and cannot
|
|
744
|
+
// double-fire a sentinel that merely stayed put. Props need no ownership
|
|
745
|
+
// check: assigning one twice, from a nested island and its parent, lands
|
|
746
|
+
// the same value.
|
|
747
|
+
scan() {
|
|
748
|
+
const found = this.element.querySelectorAll(
|
|
749
|
+
'[data-hibiki-on*="visible->"], [data-hibiki-props]'
|
|
750
|
+
)
|
|
751
|
+
for (const element of found) {
|
|
752
|
+
if (element.hasAttribute("data-hibiki-props")) applyProps(element)
|
|
753
|
+
if (
|
|
754
|
+
element.dataset.hibikiOn?.includes("visible->") &&
|
|
755
|
+
element.closest('[data-controller~="hibiki"]') === this.element
|
|
756
|
+
) {
|
|
757
|
+
this.observer.observe(element)
|
|
645
758
|
}
|
|
646
759
|
}
|
|
647
760
|
}
|
|
@@ -771,10 +884,8 @@ export { HibikiController }
|
|
|
771
884
|
// element (a structural mistake, hence the warn; the offline case stays
|
|
772
885
|
// quiet because it is expected weather).
|
|
773
886
|
export function performOn(element, action, payload = {}) {
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
if (island) return island.perform(action, payload)
|
|
777
|
-
}
|
|
887
|
+
const island = islandFor(element)
|
|
888
|
+
if (island) return island.perform(action, payload)
|
|
778
889
|
console.warn("hibiki: performOn found no island containing", element)
|
|
779
890
|
return undefined
|
|
780
891
|
}
|
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.
|