hibiki_rails 0.18.0 → 0.20.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 +112 -0
- data/app/assets/javascripts/hibiki-commands.js +105 -0
- data/app/assets/javascripts/hibiki.js +205 -27
- data/config/importmap.rb +3 -0
- data/lib/generators/hibiki/rails/install/install_generator.rb +14 -1
- data/lib/hibiki/rails/channel.rb +31 -13
- data/lib/hibiki/rails/commands.rb +130 -0
- data/lib/hibiki/rails/helpers.rb +34 -16
- data/lib/hibiki/rails/modifiers.rb +44 -0
- 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: e04ed317fc77afc03ef9dcb0ec2fc1f52833e23dd0700934e3bee3551c0f1549
|
|
4
|
+
data.tar.gz: b76c0323f43becb86476653aebd6cd58e01e1ab917c18fdf5e7e843c95f01e60
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c6b3b937fcaeba352b86432ceef1b4ec526e26e74509b83f39a868db656706fd4f89c64d6cd3d3805a6c5553376a1f09765dbd939ec28156bf3bb8cbcb696e45
|
|
7
|
+
data.tar.gz: 889c7cea1cfc1c772e6a18573e2717fbf8a569ff679a5a4f6208d2b961db6d80c4b7d8c00435e4be8783a7a57adfb22bb162d7d1624b566fedfd4f3ebbaeaf88
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,118 @@ 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.20.0 — 2026-10-12
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
**Commands.** `js` builds a list of things the browser does on an event,
|
|
12
|
+
with no round trip. `on` takes the list where it takes an action name.
|
|
13
|
+
|
|
14
|
+
```erb
|
|
15
|
+
<%= tag.button "Menu", **on(js.toggle("#menu")) %>
|
|
16
|
+
<%= tag.button "Cancel", **on(js.hide("#panel").push(:cancel)) %>
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
| Command | What it does |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `show`, `hide`, `toggle` | writes the `hidden` attribute |
|
|
22
|
+
| `add_class`, `remove_class`, `toggle_class` | one or more class names |
|
|
23
|
+
| `set_attribute`, `remove_attribute`, `toggle_attribute` | an attribute by name |
|
|
24
|
+
| `focus`, `focus_first` | focuses the target, or the first control inside it |
|
|
25
|
+
| `dispatch` | fires a `CustomEvent`, with `detail:` and `bubbles:` |
|
|
26
|
+
| `push` | performs a channel action, with `with:` as its payload |
|
|
27
|
+
| `show_modal`, `close_dialog` | a `<dialog>`'s own methods |
|
|
28
|
+
|
|
29
|
+
- A target is a CSS selector for the whole document, `{ inner: selector }`
|
|
30
|
+
for inside the control, or `{ closest: selector }` for its nearest
|
|
31
|
+
ancestor. With no target a command acts on the control. `hide`, `show`,
|
|
32
|
+
`toggle`, `focus`, `focus_first`, `show_modal` and `close_dialog` take the
|
|
33
|
+
target as their argument; the others take it as `to:`.
|
|
34
|
+
- The commands run in order and do not wait for the server.
|
|
35
|
+
- `confirm:` and `debounce:` on `on` apply to the whole list. `with:` goes on
|
|
36
|
+
`push`. `fallback: true` needs a `push` in the list.
|
|
37
|
+
- A list prevents the event's default.
|
|
38
|
+
- A command's change lasts until the server next renders that element. Keep
|
|
39
|
+
state the server should know in a signal.
|
|
40
|
+
- Commands run only on a control inside an island.
|
|
41
|
+
- `set_attribute` and its siblings refuse `on*`, `style`, `srcdoc`,
|
|
42
|
+
`data-hibiki-*`, `data-controller` and `data-action`.
|
|
43
|
+
|
|
44
|
+
**The `hibiki-rails/commands` module.** Commands need one import, beside
|
|
45
|
+
your controllers:
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
import "hibiki-rails/commands"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`hibiki:rails:install` adds it to `app/javascript/application.js`. An
|
|
52
|
+
existing app adds the line by hand, or runs the generator again. Without it
|
|
53
|
+
a command list does nothing, and the client says so once in the console.
|
|
54
|
+
|
|
55
|
+
**Dialogs the server opens.** `modal(open:)` on a `<dialog>` makes the
|
|
56
|
+
channel's state decide whether it is open. After each render the client
|
|
57
|
+
calls `showModal()` or `close()` to match.
|
|
58
|
+
|
|
59
|
+
```erb
|
|
60
|
+
<%= tag.dialog id: "editor", **on(:cancel, event: :cancel).deep_merge(modal(open: form.editing?)) do %>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
- Use `modal` for a dialog the server must know about, such as a form it may
|
|
64
|
+
refuse to save. Use `js.show_modal` and `js.close_dialog` for one it need
|
|
65
|
+
not. Do not put both on one dialog.
|
|
66
|
+
- `on` now hears a dialog's `cancel` and `close` events. `cancel` is Escape;
|
|
67
|
+
`close` also follows `close_dialog` and a close the server asked for.
|
|
68
|
+
- A morph no longer removes the `open` attribute from a dialog inside an
|
|
69
|
+
island.
|
|
70
|
+
- Keep what is typed in the dialog out of the effect that renders it, or
|
|
71
|
+
each keystroke redraws it.
|
|
72
|
+
|
|
73
|
+
### Changed
|
|
74
|
+
|
|
75
|
+
- A `focus` site inside a closed dialog does not count as on the page. It
|
|
76
|
+
takes focus when a render of this tab opens the dialog, and focus returns
|
|
77
|
+
to the control that opened it when the dialog closes.
|
|
78
|
+
|
|
79
|
+
## 0.19.0 — 2026-10-12
|
|
80
|
+
|
|
81
|
+
### Added
|
|
82
|
+
|
|
83
|
+
**History entries.** `transmit_url` takes `history: :push`, and each move of
|
|
84
|
+
the URL becomes an entry in the browser's history. Use it for state that
|
|
85
|
+
reads as a place, such as a wizard's step. The default, `history: :replace`,
|
|
86
|
+
is unchanged.
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
transmit_url(history: :push) { wizard_path(step: @step.value) }
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**The `popstate` event.** `on(:visit, event: :popstate)` on the island root
|
|
93
|
+
answers Back and Forward over those entries. The client performs the action
|
|
94
|
+
with the query of the address the browser moved to.
|
|
95
|
+
|
|
96
|
+
```erb
|
|
97
|
+
<%= island WizardChannel, **on(:visit, event: :popstate) do |cid| %>
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
def visit(data) = turn_to(data["step"])
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
- The params come from the address bar. Check them as you would a subscribe
|
|
105
|
+
param.
|
|
106
|
+
- A pushed URL becomes an entry only when it answers an action of this tab.
|
|
107
|
+
The first URL a channel sends, and one that follows another session's
|
|
108
|
+
write, replace as before.
|
|
109
|
+
- If the action refuses the address, or changes nothing, the bar is put back
|
|
110
|
+
to the URL the channel last sent. No entry is added.
|
|
111
|
+
- The payload is the flat query. A key ending in `[]` arrives as an array.
|
|
112
|
+
- `popstate` takes no `debounce:` or `fallback:`.
|
|
113
|
+
- Without a `popstate` action, Back over a pushed entry reloads the page at
|
|
114
|
+
that address. The page shows the right state if it reads the same params
|
|
115
|
+
on its first render, as a mirrored URL already requires.
|
|
116
|
+
- Coming back to a pushed entry from another page also reloads it.
|
|
117
|
+
- A page that has pushed an entry is left out of Turbo's snapshot cache.
|
|
118
|
+
|
|
7
119
|
## 0.18.0 — 2026-10-11
|
|
8
120
|
|
|
9
121
|
### Added
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// hibiki-rails/commands — things a control does in the browser, with no
|
|
2
|
+
// round trip: show and hide, classes, attributes, focus, a DOM event, a
|
|
3
|
+
// dialog, and a channel action among them.
|
|
4
|
+
//
|
|
5
|
+
// Opt-in: `import "hibiki-rails/commands"` once, beside the controllers.
|
|
6
|
+
// The view declares a list with the `js` helper (`on(js.hide("#panel")
|
|
7
|
+
// .push(:cancel))`), which the client finds on the control as
|
|
8
|
+
// data-hibiki-do and hands to the interpreter below, in order. Like the
|
|
9
|
+
// rest of data-hibiki-*, the list's wire form is private to the gem.
|
|
10
|
+
//
|
|
11
|
+
// Only the names in OPS run, and an attribute command writes nothing a
|
|
12
|
+
// handler, a style or this gem's own wiring could ride in on. Ruby keeps
|
|
13
|
+
// the same two lists (Commands::OPS and Commands::REFUSED) and raises at
|
|
14
|
+
// render; here anything outside them is skipped. Add a command in both
|
|
15
|
+
// places.
|
|
16
|
+
//
|
|
17
|
+
// What a command changes lasts until the server next renders that element.
|
|
18
|
+
// State the server should know belongs in a signal.
|
|
19
|
+
import { useCommands } from "hibiki-rails"
|
|
20
|
+
|
|
21
|
+
const REFUSED = /^(on|style$|srcdoc$|data-hibiki-|data-controller$|data-action$)/i
|
|
22
|
+
const ATTRIBUTE = /^[a-z_:][a-z0-9_:.-]*$/i
|
|
23
|
+
const ACTION = /^[a-z][a-z0-9_.-]*$/i
|
|
24
|
+
const FOCUSABLE =
|
|
25
|
+
'a[href], button:not(:disabled), input:not(:disabled):not([type="hidden"]), ' +
|
|
26
|
+
'select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])'
|
|
27
|
+
|
|
28
|
+
// A selector is the app's, and a bad one must not stop the rest of a list.
|
|
29
|
+
const query = (root, selector, all) => {
|
|
30
|
+
try {
|
|
31
|
+
return all ? [...root.querySelectorAll(selector)] : [root.closest(selector)].filter(Boolean)
|
|
32
|
+
} catch {
|
|
33
|
+
return []
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Nothing is the control; a string is looked up in the document; `inner`
|
|
38
|
+
// inside the control; `closest` among its ancestors.
|
|
39
|
+
const targets = (control, to) => {
|
|
40
|
+
if (to === undefined) return [control]
|
|
41
|
+
if (typeof to === "string") return query(document, to, true)
|
|
42
|
+
if (typeof to?.inner === "string") return query(control, to.inner, true)
|
|
43
|
+
if (typeof to?.closest === "string") return query(control, to.closest, false)
|
|
44
|
+
return []
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const classes = (names) =>
|
|
48
|
+
Array.isArray(names) ? names.filter((name) => typeof name === "string" && /^\S+$/.test(name)) : []
|
|
49
|
+
const writable = (name) => typeof name === "string" && ATTRIBUTE.test(name) && !REFUSED.test(name)
|
|
50
|
+
|
|
51
|
+
// name -> what it does to one target.
|
|
52
|
+
const OPS = {
|
|
53
|
+
show: (element) => element.removeAttribute("hidden"),
|
|
54
|
+
hide: (element) => element.setAttribute("hidden", ""),
|
|
55
|
+
toggle: (element) => element.toggleAttribute("hidden"),
|
|
56
|
+
add_class: (element, { names }) => element.classList.add(...classes(names)),
|
|
57
|
+
remove_class: (element, { names }) => element.classList.remove(...classes(names)),
|
|
58
|
+
toggle_class: (element, { names }) => {
|
|
59
|
+
for (const name of classes(names)) element.classList.toggle(name)
|
|
60
|
+
},
|
|
61
|
+
set_attribute: (element, { name, value }) => {
|
|
62
|
+
if (writable(name)) element.setAttribute(name, String(value ?? ""))
|
|
63
|
+
},
|
|
64
|
+
remove_attribute: (element, { name }) => {
|
|
65
|
+
if (writable(name)) element.removeAttribute(name)
|
|
66
|
+
},
|
|
67
|
+
toggle_attribute: (element, { name, value }) => {
|
|
68
|
+
if (!writable(name)) return
|
|
69
|
+
if (element.hasAttribute(name)) element.removeAttribute(name)
|
|
70
|
+
else element.setAttribute(name, String(value ?? ""))
|
|
71
|
+
},
|
|
72
|
+
dispatch: (element, { name, detail, bubbles }) => {
|
|
73
|
+
if (typeof name !== "string") return
|
|
74
|
+
element.dispatchEvent(new CustomEvent(name, { bubbles: bubbles !== false, detail }))
|
|
75
|
+
},
|
|
76
|
+
show_modal: (element) => {
|
|
77
|
+
if (typeof element.showModal === "function" && !element.open) element.showModal()
|
|
78
|
+
},
|
|
79
|
+
close_dialog: (element) => {
|
|
80
|
+
if (element.open) element.close?.()
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Focus goes to one element, so these take the first target only.
|
|
85
|
+
const FOCUS = {
|
|
86
|
+
focus: (element) => element,
|
|
87
|
+
focus_first: (element) => element.querySelector(FOCUSABLE)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
useCommands((ops, control, push) => {
|
|
91
|
+
for (const op of ops) {
|
|
92
|
+
if (!Array.isArray(op)) continue
|
|
93
|
+
const [name, args = {}] = op
|
|
94
|
+
if (name === "push") {
|
|
95
|
+
if (typeof args.action === "string" && ACTION.test(args.action)) push(args.action, args.with ?? {})
|
|
96
|
+
} else if (Object.hasOwn(FOCUS, name)) {
|
|
97
|
+
const [first] = targets(control, args.to)
|
|
98
|
+
if (first) FOCUS[name](first)?.focus()
|
|
99
|
+
} else if (Object.hasOwn(OPS, name)) {
|
|
100
|
+
// With no target, close_dialog closes the dialog the control is in.
|
|
101
|
+
const to = name === "close_dialog" && args.to === undefined ? { closest: "dialog" } : args.to
|
|
102
|
+
for (const element of targets(control, to)) OPS[name](element, args)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
})
|
|
@@ -64,6 +64,11 @@
|
|
|
64
64
|
// that have no attribute, assigned on connect and after each
|
|
65
65
|
// render; only the names in PROPS below, an optional "key"
|
|
66
66
|
// beside them existing to change the text
|
|
67
|
+
// command lists data-hibiki-on="click->*" with data-hibiki-do='[["hide",
|
|
68
|
+
// {"to":"#panel"}],["push",{"action":"cancel"}]]': the browser
|
|
69
|
+
// runs the list on the event; see `useCommands` below
|
|
70
|
+
// modal sites data-hibiki-modal="true|false" a <dialog> shown with
|
|
71
|
+
// showModal() or closed, on connect and after each render
|
|
67
72
|
// focus sites data-hibiki-focus="true|select" takes focus, and has its
|
|
68
73
|
// text selected, when a render this tab caused inserts it
|
|
69
74
|
// turbo streams data-hibiki-seq="3" the render's number, and
|
|
@@ -93,7 +98,9 @@
|
|
|
93
98
|
//
|
|
94
99
|
// The left side of `->` is a hibiki event name: click, change, input,
|
|
95
100
|
// and submit are delegated DOM listeners; `visible` is a pseudo-event
|
|
96
|
-
// backed by an IntersectionObserver (the element entering the viewport)
|
|
101
|
+
// backed by an IntersectionObserver (the element entering the viewport);
|
|
102
|
+
// `popstate` is the window's, heard for the history entries a pushed
|
|
103
|
+
// `{ url }` frame made.
|
|
97
104
|
// Everything that is not "which event" is a sibling attribute, so the
|
|
98
105
|
// token grammar never has to grow.
|
|
99
106
|
//
|
|
@@ -185,6 +192,57 @@ let consumer
|
|
|
185
192
|
// island pins nothing even if disconnect never ran.
|
|
186
193
|
const islands = new WeakMap()
|
|
187
194
|
|
|
195
|
+
// History entries a pushed `{ url }` frame made, by the key in their state,
|
|
196
|
+
// and the one window listener that answers Back and Forward over them.
|
|
197
|
+
//
|
|
198
|
+
// Turbo hears popstate first, whatever the listener's phase: the browser
|
|
199
|
+
// runs a window's listeners in the order they were added. It answers an
|
|
200
|
+
// entry carrying its own state with a restore visit, and an entry without
|
|
201
|
+
// it by adopting it in place. So these entries carry only the key. The
|
|
202
|
+
// island that made one answers it (pop). An entry whose island has left
|
|
203
|
+
// the page, or declares no `popstate`, is loaded afresh: Turbo has by then
|
|
204
|
+
// filed a snapshot of the page on show under the entry's address, and a
|
|
205
|
+
// new document is the one thing that leaves none of that behind.
|
|
206
|
+
//
|
|
207
|
+
// Always listening, not from the first push: after a reload the browser
|
|
208
|
+
// still counts the entries the old document pushed as this document's, and
|
|
209
|
+
// answers Back over them with a popstate no island here made.
|
|
210
|
+
const pushers = new Map()
|
|
211
|
+
window.addEventListener("popstate", (event) => {
|
|
212
|
+
const key = event.state?.hibiki
|
|
213
|
+
if (key && !pushers.get(key)?.pop?.(event)) window.location.reload()
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
// The command module's seam. A control whose token names `*` runs a list
|
|
217
|
+
// of commands (data-hibiki-do) in place of one action; what runs them is
|
|
218
|
+
// the opt-in module `hibiki-rails/commands`, which hands its interpreter
|
|
219
|
+
// over here on import. Without it the list does nothing, and says so once.
|
|
220
|
+
let commands
|
|
221
|
+
let warned = false
|
|
222
|
+
export function useCommands(interpreter) {
|
|
223
|
+
commands = interpreter
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// A <dialog> the server declares open or closed (Helpers#modal): the state
|
|
227
|
+
// is a method call away from any attribute, so it is made to match here,
|
|
228
|
+
// on connect and after each render.
|
|
229
|
+
const applyModal = (dialog) => {
|
|
230
|
+
if (typeof dialog.showModal !== "function") return
|
|
231
|
+
const open = dialog.dataset.hibikiModal === "true"
|
|
232
|
+
if (open && !dialog.open) dialog.showModal()
|
|
233
|
+
else if (!open && dialog.open) dialog.close()
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// The browser writes `open` on a dialog it shows, and no server HTML has
|
|
237
|
+
// it, so a morph would take it off and leave the dialog shown but closed.
|
|
238
|
+
// Inside an island that attribute is the browser's.
|
|
239
|
+
document.addEventListener("turbo:before-morph-attribute", (event) => {
|
|
240
|
+
const { target } = event
|
|
241
|
+
if (target.localName === "dialog" && event.detail?.attributeName === "open" && islandFor(target)) {
|
|
242
|
+
event.preventDefault()
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
|
|
188
246
|
// The generic island CONTAINING element, or undefined. The walk performOn
|
|
189
247
|
// uses, exported for the motion module's `own` policy — which reads the
|
|
190
248
|
// island's trip records, so a ChannelController subclass (not in the map)
|
|
@@ -310,6 +368,9 @@ export class ChannelController extends Controller {
|
|
|
310
368
|
this.settledAt = -Infinity
|
|
311
369
|
this.lastControl = null
|
|
312
370
|
this.queued = []
|
|
371
|
+
// Marks the history entries this island made; see replaceUrl().
|
|
372
|
+
this.historyKey = `${Date.now()}-${Math.random()}`
|
|
373
|
+
this.popping = undefined
|
|
313
374
|
this.setState("connecting")
|
|
314
375
|
}
|
|
315
376
|
|
|
@@ -339,6 +400,8 @@ export class ChannelController extends Controller {
|
|
|
339
400
|
|
|
340
401
|
disconnect() {
|
|
341
402
|
this.aborted = true
|
|
403
|
+
pushers.delete(this.historyKey)
|
|
404
|
+
this.popping = undefined // the bar is the next page's now
|
|
342
405
|
this.settleAll()
|
|
343
406
|
this.subscription?.unsubscribe()
|
|
344
407
|
this.subscription = undefined
|
|
@@ -503,6 +566,7 @@ export class ChannelController extends Controller {
|
|
|
503
566
|
}
|
|
504
567
|
|
|
505
568
|
settle(seq) {
|
|
569
|
+
if (this.popping === seq) this.popped()
|
|
506
570
|
const record = this.busy.get(seq)
|
|
507
571
|
if (!record) return
|
|
508
572
|
clearTimeout(record.ceiling)
|
|
@@ -595,7 +659,8 @@ export class ChannelController extends Controller {
|
|
|
595
659
|
// textContent assignment keeps values text-only and preserves each
|
|
596
660
|
// site's own tag/classes, so per-placeholder styling survives updates.
|
|
597
661
|
//
|
|
598
|
-
// { url } — mirror graph state into the address bar (transmit_url)
|
|
662
|
+
// { url } — mirror graph state into the address bar (transmit_url);
|
|
663
|
+
// with `push`, as a history entry.
|
|
599
664
|
//
|
|
600
665
|
// { html } — a fragment: swap it in by its root id. The swap is holdable
|
|
601
666
|
// the way Turbo's is: `hibiki:before-render` bubbles from the island
|
|
@@ -607,7 +672,7 @@ export class ChannelController extends Controller {
|
|
|
607
672
|
//
|
|
608
673
|
// Anything else is not ours to interpret. Subclasses may override, but
|
|
609
674
|
// should call super (or handle `value`) to keep reactive values live.
|
|
610
|
-
received({ html, value, url }) {
|
|
675
|
+
received({ html, value, url, push }) {
|
|
611
676
|
if (value) {
|
|
612
677
|
const selector = `[data-hibiki-value="${CSS.escape(value.name)}"]`
|
|
613
678
|
for (const site of document.querySelectorAll(selector)) {
|
|
@@ -615,7 +680,7 @@ export class ChannelController extends Controller {
|
|
|
615
680
|
}
|
|
616
681
|
return
|
|
617
682
|
}
|
|
618
|
-
if (url !== undefined) return this.replaceUrl(url)
|
|
683
|
+
if (url !== undefined) return this.replaceUrl(url, push)
|
|
619
684
|
if (!html) return
|
|
620
685
|
const template = document.createElement("template")
|
|
621
686
|
template.innerHTML = html
|
|
@@ -633,15 +698,48 @@ export class ChannelController extends Controller {
|
|
|
633
698
|
return event.detail.render()
|
|
634
699
|
}
|
|
635
700
|
|
|
636
|
-
// replaceState
|
|
637
|
-
//
|
|
638
|
-
//
|
|
639
|
-
//
|
|
640
|
-
//
|
|
641
|
-
|
|
701
|
+
// replaceState unless the channel asked for entries: the URL is a mirror
|
|
702
|
+
// of graph state, and Back leaves the page normally. Same-origin only, so
|
|
703
|
+
// a channel can move the bar solely within its own app (replaceState
|
|
704
|
+
// would throw on a cross-origin URL; refusing keeps it silent and
|
|
705
|
+
// intentional).
|
|
706
|
+
//
|
|
707
|
+
// A pushed URL becomes an entry only when it answers this tab's own
|
|
708
|
+
// action, so neither the first frame nor another session's write adds
|
|
709
|
+
// one, and never while a Back is being answered: an action that refuses
|
|
710
|
+
// the popped URL corrects the bar in place. The entry it leaves and the
|
|
711
|
+
// one it makes carry this island's key in place of Turbo's state; see
|
|
712
|
+
// `pushers`. The page is also kept out of Turbo's snapshot cache from
|
|
713
|
+
// then on, since Turbo no longer knows which address a snapshot is of.
|
|
714
|
+
replaceUrl(url, push = false) {
|
|
642
715
|
const resolved = new URL(url, window.location.href)
|
|
643
716
|
if (resolved.origin !== window.location.origin) return
|
|
644
|
-
|
|
717
|
+
this.url = resolved.href
|
|
718
|
+
if (!push || this.popping !== undefined || !this.own()) {
|
|
719
|
+
return history.replaceState(history.state, "", resolved)
|
|
720
|
+
}
|
|
721
|
+
if (resolved.href === window.location.href) return
|
|
722
|
+
pushers.set(this.historyKey, this)
|
|
723
|
+
window.Turbo?.cache?.exemptPageFromCache?.()
|
|
724
|
+
history.replaceState(this.entry(), "")
|
|
725
|
+
history.pushState(this.entry(), "", resolved)
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// The state of an entry this island answers for: what is there now,
|
|
729
|
+
// less Turbo's part.
|
|
730
|
+
entry() {
|
|
731
|
+
const { turbo, ...rest } = history.state ?? {}
|
|
732
|
+
return { ...rest, hibiki: this.historyKey }
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// The Back being answered is over. An action that changed nothing sent no
|
|
736
|
+
// URL, so the bar still shows the popped one: put back the last the
|
|
737
|
+
// channel gave, which is the state the page is in.
|
|
738
|
+
popped() {
|
|
739
|
+
this.popping = undefined
|
|
740
|
+
if (this.url && this.url !== window.location.href) {
|
|
741
|
+
history.replaceState(history.state, "", this.url)
|
|
742
|
+
}
|
|
645
743
|
}
|
|
646
744
|
|
|
647
745
|
// `static channel = "..."` wins; otherwise infer Rails-style from the
|
|
@@ -733,6 +831,7 @@ const KEYS = {
|
|
|
733
831
|
}
|
|
734
832
|
const KEYED = '[data-hibiki-on*="keydown."]'
|
|
735
833
|
const HOTKEYED = '[data-hibiki-on*="@window"]'
|
|
834
|
+
const POPPED = '[data-hibiki-on*="popstate->"]'
|
|
736
835
|
const ISLAND = '[data-controller~="hibiki"]'
|
|
737
836
|
|
|
738
837
|
// A key press that is a gesture: not part of an IME composition (Safari
|
|
@@ -847,6 +946,9 @@ export default class HibikiController extends ChannelController {
|
|
|
847
946
|
for (const site of this.element.querySelectorAll("[data-hibiki-props]")) {
|
|
848
947
|
applyProps(site)
|
|
849
948
|
}
|
|
949
|
+
for (const dialog of this.element.querySelectorAll("[data-hibiki-modal]")) {
|
|
950
|
+
applyModal(dialog)
|
|
951
|
+
}
|
|
850
952
|
|
|
851
953
|
// Root-scoped delegation (bound to the island, not document): controls
|
|
852
954
|
// inside server-replaced fragments keep working with no rebinding.
|
|
@@ -856,6 +958,13 @@ export default class HibikiController extends ChannelController {
|
|
|
856
958
|
this.element.addEventListener(type, handler)
|
|
857
959
|
return [type, handler]
|
|
858
960
|
})
|
|
961
|
+
// A dialog's `cancel` (Escape, before it closes) and `close` do not
|
|
962
|
+
// bubble, so they are heard on the way down.
|
|
963
|
+
for (const type of ["cancel", "close"]) {
|
|
964
|
+
const handler = (event) => this.forward(event)
|
|
965
|
+
this.element.addEventListener(type, handler, true)
|
|
966
|
+
this.listeners.push([type, handler, true])
|
|
967
|
+
}
|
|
859
968
|
// Keys take their own path: a key names its control by a token, not by
|
|
860
969
|
// being the nearest one.
|
|
861
970
|
const keydown = (event) => this.key(event)
|
|
@@ -877,7 +986,7 @@ export default class HibikiController extends ChannelController {
|
|
|
877
986
|
// page's own marks already seen.
|
|
878
987
|
this.marks = new Map()
|
|
879
988
|
for (const site of this.element.querySelectorAll("[data-hibiki-focus]")) {
|
|
880
|
-
if (
|
|
989
|
+
if (this.marked(site)) this.marks.set(site, undefined)
|
|
881
990
|
}
|
|
882
991
|
|
|
883
992
|
// `visible` is not a DOM event, so it needs its own observer beside the
|
|
@@ -944,8 +1053,8 @@ export default class HibikiController extends ChannelController {
|
|
|
944
1053
|
|
|
945
1054
|
disconnect() {
|
|
946
1055
|
islands.delete(this.element)
|
|
947
|
-
for (const [type, handler] of this.listeners) {
|
|
948
|
-
this.element.removeEventListener(type, handler)
|
|
1056
|
+
for (const [type, handler, capture] of this.listeners) {
|
|
1057
|
+
this.element.removeEventListener(type, handler, capture)
|
|
949
1058
|
}
|
|
950
1059
|
document.removeEventListener("turbo:before-stream-render", this.streamRender)
|
|
951
1060
|
document.removeEventListener("turbo:before-render", this.pageBefore)
|
|
@@ -1094,8 +1203,11 @@ export default class HibikiController extends ChannelController {
|
|
|
1094
1203
|
if (self && index === 0) {
|
|
1095
1204
|
const twin = found(ids[0])
|
|
1096
1205
|
twin.focus()
|
|
1097
|
-
|
|
1098
|
-
|
|
1206
|
+
// It may not have taken it: a field of a dialog now closed.
|
|
1207
|
+
if (document.activeElement === twin) {
|
|
1208
|
+
if (start != null) twin.setSelectionRange?.(start, end)
|
|
1209
|
+
return
|
|
1210
|
+
}
|
|
1099
1211
|
}
|
|
1100
1212
|
const candidates = [opener && this.twin(opener)]
|
|
1101
1213
|
if (index === (self ? 1 : 0)) candidates.push(...found(ids[index]).querySelectorAll(FOCUSABLE))
|
|
@@ -1107,12 +1219,13 @@ export default class HibikiController extends ChannelController {
|
|
|
1107
1219
|
|
|
1108
1220
|
// The control a gesture came from, or the one a render has put in its
|
|
1109
1221
|
// place: the same id, else the same action and payload.
|
|
1110
|
-
twin({ element, id, on, payload }) {
|
|
1222
|
+
twin({ element, id, on, payload, ops }) {
|
|
1111
1223
|
if (element.isConnected) return element
|
|
1112
1224
|
const twin = id
|
|
1113
1225
|
? document.getElementById(id)
|
|
1114
1226
|
: [...this.element.querySelectorAll("[data-hibiki-on]")].find(
|
|
1115
|
-
(
|
|
1227
|
+
({ dataset }) =>
|
|
1228
|
+
dataset.hibikiOn === on && dataset.hibikiWith === payload && dataset.hibikiDo === ops
|
|
1116
1229
|
)
|
|
1117
1230
|
return twin?.closest(ISLAND) === this.element ? twin : null
|
|
1118
1231
|
}
|
|
@@ -1129,9 +1242,17 @@ export default class HibikiController extends ChannelController {
|
|
|
1129
1242
|
// a listener twice is a no-op too. Props need no ownership check: assigning one twice,
|
|
1130
1243
|
// from a nested island and its parent, lands the same value. Nor does
|
|
1131
1244
|
// the window listener, which finds nothing of its own to fire.
|
|
1245
|
+
// A focus site this island counts as on the page: its own, and not
|
|
1246
|
+
// inside a closed dialog. So a dialog's field is new on the render that
|
|
1247
|
+
// opens the dialog, though its markup was there all along, and gone on
|
|
1248
|
+
// the one that closes it, which is when focus goes back to the opener.
|
|
1249
|
+
marked(site) {
|
|
1250
|
+
return site.closest(ISLAND) === this.element && !site.closest("dialog:not([open])")
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1132
1253
|
scan({ own = false, control, trail } = {}) {
|
|
1133
1254
|
const found = this.element.querySelectorAll(
|
|
1134
|
-
`[data-hibiki-on*="visible->"], [data-hibiki-props], [data-hibiki-focus], ${HOTKEYED}`
|
|
1255
|
+
`[data-hibiki-on*="visible->"], [data-hibiki-props], [data-hibiki-focus], [data-hibiki-modal], ${HOTKEYED}`
|
|
1135
1256
|
)
|
|
1136
1257
|
let hotkeys = this.element.matches(HOTKEYED)
|
|
1137
1258
|
// A site is the one seen before when it is that element or, after a
|
|
@@ -1142,7 +1263,9 @@ export default class HibikiController extends ChannelController {
|
|
|
1142
1263
|
const ids = new Set()
|
|
1143
1264
|
let site
|
|
1144
1265
|
for (const element of found) {
|
|
1145
|
-
|
|
1266
|
+
// First: a focus site inside the dialog is on the page once it opens.
|
|
1267
|
+
if (element.hasAttribute("data-hibiki-modal")) applyModal(element)
|
|
1268
|
+
if (element.hasAttribute("data-hibiki-focus") && this.marked(element)) {
|
|
1146
1269
|
marks.set(element, this.marks.get(element) ?? seen.get(element.id))
|
|
1147
1270
|
if (element.id) ids.add(element.id)
|
|
1148
1271
|
if (!this.marks.has(element) && !seen.has(element.id)) site ??= element
|
|
@@ -1168,7 +1291,13 @@ export default class HibikiController extends ChannelController {
|
|
|
1168
1291
|
if (own && site && !this.unsent.has(trail?.element)) {
|
|
1169
1292
|
if (control) {
|
|
1170
1293
|
const { id, dataset } = control
|
|
1171
|
-
marks.set(site, {
|
|
1294
|
+
marks.set(site, {
|
|
1295
|
+
element: control,
|
|
1296
|
+
id,
|
|
1297
|
+
on: dataset.hibikiOn,
|
|
1298
|
+
payload: dataset.hibikiWith,
|
|
1299
|
+
ops: dataset.hibikiDo
|
|
1300
|
+
})
|
|
1172
1301
|
}
|
|
1173
1302
|
const take = () => {
|
|
1174
1303
|
site.focus()
|
|
@@ -1218,6 +1347,31 @@ export default class HibikiController extends ChannelController {
|
|
|
1218
1347
|
}
|
|
1219
1348
|
}
|
|
1220
1349
|
|
|
1350
|
+
// Back or Forward landed on an entry this island made, or the one it
|
|
1351
|
+
// first pushed from: the control declaring `popstate`, the root as a
|
|
1352
|
+
// rule, performs its action with the entry's query. Turbo has adopted the
|
|
1353
|
+
// entry by now and written its own state there, which would make the
|
|
1354
|
+
// next visit to it a restore; the entry's own state goes back. False
|
|
1355
|
+
// when nothing here answers, and the page is loaded afresh instead.
|
|
1356
|
+
pop(event) {
|
|
1357
|
+
const control = [this.element, ...this.element.querySelectorAll(POPPED)].find(
|
|
1358
|
+
(element) => element.closest(ISLAND) === this.element && element.matches(POPPED)
|
|
1359
|
+
)
|
|
1360
|
+
const token = control?.dataset.hibikiOn.split(/\s+/).find((t) => t.startsWith("popstate->"))
|
|
1361
|
+
if (!token) return false
|
|
1362
|
+
history.replaceState(event.state, "")
|
|
1363
|
+
const payload = control.dataset.hibikiWith ? JSON.parse(control.dataset.hibikiWith) : {}
|
|
1364
|
+
const query = new URLSearchParams(window.location.search)
|
|
1365
|
+
for (const key of new Set(query.keys())) {
|
|
1366
|
+
const all = query.getAll(key)
|
|
1367
|
+
payload[payloadKey(key)] = key.endsWith("[]") ? all : all.at(-1)
|
|
1368
|
+
}
|
|
1369
|
+
this.flush()
|
|
1370
|
+
this.popping = this.perform(token.slice(10), payload)
|
|
1371
|
+
if (this.popping === undefined) this.popped() // offline: nothing will answer
|
|
1372
|
+
return true
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1221
1375
|
// A matched key is the control's gesture, so its default goes. A fallback
|
|
1222
1376
|
// control on an island that is not ready stands aside: a key has no
|
|
1223
1377
|
// native destination, but its default may be the form's own submit.
|
|
@@ -1268,7 +1422,9 @@ export default class HibikiController extends ChannelController {
|
|
|
1268
1422
|
// Before the confirm, not after: declining must not let the form (or
|
|
1269
1423
|
// a fallback control's navigation) proceed. Optional call because the
|
|
1270
1424
|
// `visible` pseudo-event arrives as a plain object.
|
|
1271
|
-
|
|
1425
|
+
// A command list is the control's whole behaviour, so its default goes.
|
|
1426
|
+
const listed = action === "*"
|
|
1427
|
+
if (event.type === "submit" || fallback || listed) event.preventDefault?.()
|
|
1272
1428
|
|
|
1273
1429
|
const message = control.dataset.hibikiConfirm
|
|
1274
1430
|
if (message && !window.confirm(message)) return
|
|
@@ -1277,7 +1433,7 @@ export default class HibikiController extends ChannelController {
|
|
|
1277
1433
|
// input sends what the user finished typing rather than the first
|
|
1278
1434
|
// keystroke that started the timer.
|
|
1279
1435
|
const wait = Number(control.dataset.hibikiDebounce)
|
|
1280
|
-
const fire = () => this.send(control, event, action)
|
|
1436
|
+
const fire = () => (listed ? this.command(control, event) : this.send(control, event, action))
|
|
1281
1437
|
if (wait > 0) {
|
|
1282
1438
|
const field = event.target
|
|
1283
1439
|
if (event.type !== "keydown" && typed(field)) {
|
|
@@ -1322,10 +1478,32 @@ export default class HibikiController extends ChannelController {
|
|
|
1322
1478
|
}
|
|
1323
1479
|
}
|
|
1324
1480
|
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1481
|
+
// The control's command list, handed to the command module with the one
|
|
1482
|
+
// thing only this file can do: send an action as the control's own.
|
|
1483
|
+
command(control, event) {
|
|
1484
|
+
if (!commands) {
|
|
1485
|
+
if (!warned) console.warn('hibiki: a control has commands, but "hibiki-rails/commands" is not imported')
|
|
1486
|
+
warned = true
|
|
1487
|
+
return
|
|
1488
|
+
}
|
|
1489
|
+
let ops
|
|
1490
|
+
try {
|
|
1491
|
+
ops = JSON.parse(control.dataset.hibikiDo)
|
|
1492
|
+
} catch {
|
|
1493
|
+
return
|
|
1494
|
+
}
|
|
1495
|
+
if (!Array.isArray(ops)) return
|
|
1496
|
+
commands(ops, control, (action, payload) => this.send(control, event, action, payload))
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
// `given` is a pushed command's payload, where a plain action reads the
|
|
1500
|
+
// control's own.
|
|
1501
|
+
send(control, event, action, given) {
|
|
1502
|
+
const payload = given
|
|
1503
|
+
? { ...given }
|
|
1504
|
+
: control.dataset.hibikiWith
|
|
1505
|
+
? JSON.parse(control.dataset.hibikiWith)
|
|
1506
|
+
: {}
|
|
1329
1507
|
if (event.type === "submit") {
|
|
1330
1508
|
Object.assign(payload, formPayload(control))
|
|
1331
1509
|
} else if (control.name && (event.type === "change" || event.type === "input")) {
|
data/config/importmap.rb
CHANGED
|
@@ -8,3 +8,6 @@
|
|
|
8
8
|
pin "hibiki-rails", to: "hibiki.js"
|
|
9
9
|
# The opt-in motion module; it imports "hibiki-rails" by the pin above.
|
|
10
10
|
pin "hibiki-rails/motion", to: "hibiki-motion.js"
|
|
11
|
+
# The opt-in commands module (`on(js.hide(...))`); the install generator
|
|
12
|
+
# imports it.
|
|
13
|
+
pin "hibiki-rails/commands", to: "hibiki-commands.js"
|
|
@@ -18,7 +18,7 @@ module Hibiki
|
|
|
18
18
|
|
|
19
19
|
desc "Wires the packaged hibiki client: registers the \"hibiki\" Stimulus " \
|
|
20
20
|
"controller, includes Hibiki::Rails::Helpers in ApplicationHelper, " \
|
|
21
|
-
"and creates the ApplicationCable boilerplate."
|
|
21
|
+
"imports the commands module, and creates the ApplicationCable boilerplate."
|
|
22
22
|
|
|
23
23
|
# Exactly the lines `stimulus:manifest:update` emits for the shim,
|
|
24
24
|
# so a later manifest run converges instead of duplicating.
|
|
@@ -30,6 +30,12 @@ module Hibiki
|
|
|
30
30
|
|
|
31
31
|
INCLUDE_LINE = " include Hibiki::Rails::Helpers\n"
|
|
32
32
|
|
|
33
|
+
# The opt-in module that runs `on(js....)` command lists. One line in
|
|
34
|
+
# the file both worlds have: the engine's pin answers it in importmap
|
|
35
|
+
# apps, the npm package under a bundler.
|
|
36
|
+
APPLICATION_JS = "app/javascript/application.js"
|
|
37
|
+
COMMANDS_IMPORT = %(import "hibiki-rails/commands"\n)
|
|
38
|
+
|
|
33
39
|
# Stock Rails apps don't have these until the first `rails g
|
|
34
40
|
# channel` — the hibiki:rails:* generators write their channels
|
|
35
41
|
# directly, so install supplies the boilerplate they inherit.
|
|
@@ -63,6 +69,13 @@ module Hibiki
|
|
|
63
69
|
after: /module ApplicationHelper\s*\n/
|
|
64
70
|
end
|
|
65
71
|
|
|
72
|
+
def import_commands
|
|
73
|
+
return say_status :identical, APPLICATION_JS, :blue if wired?(APPLICATION_JS, COMMANDS_IMPORT.strip)
|
|
74
|
+
return manual_wiring(APPLICATION_JS, COMMANDS_IMPORT) unless exists?(APPLICATION_JS)
|
|
75
|
+
|
|
76
|
+
append_to_file APPLICATION_JS, COMMANDS_IMPORT
|
|
77
|
+
end
|
|
78
|
+
|
|
66
79
|
# Presence is enough — an app's own ApplicationCable (customized
|
|
67
80
|
# or not) is never touched.
|
|
68
81
|
def create_application_cable
|
data/lib/hibiki/rails/channel.rb
CHANGED
|
@@ -30,11 +30,23 @@ module Hibiki
|
|
|
30
30
|
# other public method would be picked up by Channel#action_methods and
|
|
31
31
|
# become client-invocable.
|
|
32
32
|
module Channel
|
|
33
|
+
# #transmit_url's history option, allowlisted so a typo names itself.
|
|
34
|
+
HISTORIES = %i[replace push].freeze
|
|
35
|
+
private_constant :HISTORIES
|
|
36
|
+
|
|
33
37
|
def self.included(base)
|
|
34
38
|
base.extend(ClassMethods)
|
|
35
39
|
base.include(Broadcasts)
|
|
36
40
|
end
|
|
37
41
|
|
|
42
|
+
# What a history mode adds to the `{ url: }` frame.
|
|
43
|
+
def self.url_frame(history)
|
|
44
|
+
return history == :push ? { push: true } : {} if HISTORIES.include?(history)
|
|
45
|
+
|
|
46
|
+
raise ArgumentError,
|
|
47
|
+
"transmit_url history #{history.inspect} must be one of #{HISTORIES.map(&:inspect).join(', ')}"
|
|
48
|
+
end
|
|
49
|
+
|
|
38
50
|
module ClassMethods
|
|
39
51
|
# Lifecycle hooks, never client-invocable actions. Performing
|
|
40
52
|
# "build_graph" would rebuild the graph and leak the old root;
|
|
@@ -177,24 +189,30 @@ module Hibiki
|
|
|
177
189
|
end
|
|
178
190
|
|
|
179
191
|
# Mirror graph state into the page's address bar: wraps the block in
|
|
180
|
-
# an effect that transmits `{ url: }
|
|
181
|
-
#
|
|
182
|
-
#
|
|
183
|
-
#
|
|
184
|
-
#
|
|
185
|
-
#
|
|
186
|
-
#
|
|
187
|
-
#
|
|
188
|
-
#
|
|
189
|
-
#
|
|
190
|
-
# the
|
|
191
|
-
|
|
192
|
+
# an effect that transmits `{ url: }`, and the packaged client moves
|
|
193
|
+
# the bar to it. Compute a same-origin path ("/songs?page=2" — the
|
|
194
|
+
# client refuses anything else), and omit params that hold their
|
|
195
|
+
# defaults, or the bar grows noise on the first transmit. Pair it with
|
|
196
|
+
# first-paint code that READS those params (controller +
|
|
197
|
+
# subscribe-params seeding), so the mirrored URL reloads to the state
|
|
198
|
+
# it names. Equality-gated like #transmit_value: re-runs track every
|
|
199
|
+
# signal read, re-sends only when the URL actually moved.
|
|
200
|
+
#
|
|
201
|
+
# history: :replace (the default) is replaceState: the URL is a MIRROR
|
|
202
|
+
# of state, not a history entry, so Back leaves the page normally.
|
|
203
|
+
# history: :push makes each move an entry, for state that reads as a
|
|
204
|
+
# place (a wizard's step). Back then needs an answer: put
|
|
205
|
+
# `on(:visit, event: :popstate)` on the island root, and the client
|
|
206
|
+
# performs that action with the popped URL's query params. They come
|
|
207
|
+
# from the address bar, so treat them like a subscribe param.
|
|
208
|
+
def transmit_url(history: :replace, &compute)
|
|
209
|
+
frame = Channel.url_frame(history)
|
|
192
210
|
last = Object.new
|
|
193
211
|
Hibiki::Effect.new do
|
|
194
212
|
url = compute.call.to_s
|
|
195
213
|
unless url == last
|
|
196
214
|
last = url
|
|
197
|
-
transmit({ url: })
|
|
215
|
+
transmit({ url: }.merge(frame))
|
|
198
216
|
end
|
|
199
217
|
end
|
|
200
218
|
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Hibiki
|
|
6
|
+
module Rails
|
|
7
|
+
# What Helpers#js builds: a list of things the browser does on an event
|
|
8
|
+
# with no round trip, in order. Each method returns a new list, so one
|
|
9
|
+
# can be kept and extended:
|
|
10
|
+
#
|
|
11
|
+
# js.toggle("#menu")
|
|
12
|
+
# js.hide("#panel").push(:cancel)
|
|
13
|
+
# js.add_class("open", to: { closest: "li" })
|
|
14
|
+
#
|
|
15
|
+
# A target is a CSS selector looked up in the whole document,
|
|
16
|
+
# `{ inner: selector }` (inside the control), `{ closest: selector }`
|
|
17
|
+
# (the control's nearest ancestor), or nothing, which is the control
|
|
18
|
+
# itself. The client runs only the names in OPS and keeps the same list
|
|
19
|
+
# (OPS in hibiki-commands.js). Add a command in both places.
|
|
20
|
+
class Commands
|
|
21
|
+
OPS = %w[
|
|
22
|
+
show hide toggle add_class remove_class toggle_class set_attribute remove_attribute
|
|
23
|
+
toggle_attribute focus focus_first dispatch push show_modal close_dialog
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
# An attribute a command may not write: a handler, a style, markup, or
|
|
27
|
+
# the wiring of this gem and Stimulus.
|
|
28
|
+
REFUSED = /\A(on|style\z|srcdoc\z|data-hibiki-|data-controller\z|data-action\z)/i
|
|
29
|
+
ATTRIBUTE = /\A[a-z_:][a-z0-9_:.-]*\z/i
|
|
30
|
+
SCOPES = %i[inner closest].freeze
|
|
31
|
+
# A dispatched event's name; app events are often namespaced by a colon.
|
|
32
|
+
EVENT = /\A[a-z][a-z0-9_.:-]*\z/i
|
|
33
|
+
|
|
34
|
+
attr_reader :ops
|
|
35
|
+
|
|
36
|
+
def initialize(ops = [])
|
|
37
|
+
@ops = ops.freeze
|
|
38
|
+
freeze
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Show, hide or flip an element by its `hidden` attribute, which is
|
|
42
|
+
# also what the server renders it with.
|
|
43
|
+
def show(to = nil) = add("show", to:)
|
|
44
|
+
def hide(to = nil) = add("hide", to:)
|
|
45
|
+
def toggle(to = nil) = add("toggle", to:)
|
|
46
|
+
|
|
47
|
+
def add_class(names, to: nil) = add("add_class", to:, names: Commands.names(names))
|
|
48
|
+
def remove_class(names, to: nil) = add("remove_class", to:, names: Commands.names(names))
|
|
49
|
+
def toggle_class(names, to: nil) = add("toggle_class", to:, names: Commands.names(names))
|
|
50
|
+
|
|
51
|
+
def set_attribute(name, value, to: nil)
|
|
52
|
+
add("set_attribute", to:, name: Commands.attribute(name), value: value.to_s)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def remove_attribute(name, to: nil) = add("remove_attribute", to:, name: Commands.attribute(name))
|
|
56
|
+
|
|
57
|
+
# Present with `value`, or absent.
|
|
58
|
+
def toggle_attribute(name, value = "", to: nil)
|
|
59
|
+
add("toggle_attribute", to:, name: Commands.attribute(name), value: value.to_s)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def focus(to = nil) = add("focus", to:)
|
|
63
|
+
|
|
64
|
+
# The first control inside the target that can take focus.
|
|
65
|
+
def focus_first(to = nil) = add("focus_first", to:)
|
|
66
|
+
|
|
67
|
+
# A CustomEvent on the target, for the page's own JavaScript.
|
|
68
|
+
def dispatch(name, to: nil, detail: nil, bubbles: true)
|
|
69
|
+
add("dispatch", to:, name: Commands.event(name), detail:, bubbles: (false unless bubbles))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# A channel action, as `on(:action, with:)` sends it.
|
|
73
|
+
def push(action, with: nil) = add("push", action: Helpers.event_name(action), with:)
|
|
74
|
+
|
|
75
|
+
# A <dialog>, by its own methods: the browser keeps focus inside it,
|
|
76
|
+
# closes it on Escape and gives focus back. With no target,
|
|
77
|
+
# close_dialog closes the one the control is in.
|
|
78
|
+
def show_modal(to) = add("show_modal", to: to || raise(ArgumentError, "show_modal needs a target"))
|
|
79
|
+
def close_dialog(to = nil) = add("close_dialog", to:)
|
|
80
|
+
|
|
81
|
+
def push? = ops.any? { it.first == "push" }
|
|
82
|
+
def empty? = ops.empty?
|
|
83
|
+
def to_json(*) = JSON.generate(ops)
|
|
84
|
+
|
|
85
|
+
def self.names(names)
|
|
86
|
+
list = Array(names).flat_map { it.to_s.split }
|
|
87
|
+
raise ArgumentError, "a class command needs at least one class name" if list.empty?
|
|
88
|
+
|
|
89
|
+
list
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.event(name)
|
|
93
|
+
name = name.to_s
|
|
94
|
+
return name if EVENT.match?(name)
|
|
95
|
+
|
|
96
|
+
raise ArgumentError, "dispatched event #{name.inspect} must match #{EVENT.inspect}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.attribute(name)
|
|
100
|
+
name = name.to_s
|
|
101
|
+
return name if ATTRIBUTE.match?(name) && !REFUSED.match?(name)
|
|
102
|
+
|
|
103
|
+
raise ArgumentError, "attribute #{name.inspect} is not one a command may write"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.target(to)
|
|
107
|
+
return selector(to) if to.is_a?(String)
|
|
108
|
+
return { to.keys.first.to_s => selector(to.values.first) } if scoped?(to)
|
|
109
|
+
|
|
110
|
+
raise ArgumentError,
|
|
111
|
+
"command target #{to.inspect} must be a selector, { inner: selector } or { closest: selector }"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def self.scoped?(to) = to.is_a?(Hash) && to.size == 1 && SCOPES.include?(to.keys.first)
|
|
115
|
+
|
|
116
|
+
def self.selector(value)
|
|
117
|
+
return value if value.is_a?(String) && !value.strip.empty?
|
|
118
|
+
|
|
119
|
+
raise ArgumentError, "command selector #{value.inspect} must be a non-empty string"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def add(name, to: nil, **args)
|
|
125
|
+
args[:to] = Commands.target(to) unless to.nil?
|
|
126
|
+
self.class.new([*ops, [name, args.compact.transform_keys(&:to_s)]])
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
data/lib/hibiki/rails/helpers.rb
CHANGED
|
@@ -6,6 +6,8 @@ require "securerandom"
|
|
|
6
6
|
|
|
7
7
|
require_relative "dom_props"
|
|
8
8
|
require_relative "keys"
|
|
9
|
+
require_relative "modifiers"
|
|
10
|
+
require_relative "commands"
|
|
9
11
|
|
|
10
12
|
module Hibiki
|
|
11
13
|
module Rails
|
|
@@ -187,6 +189,10 @@ module Hibiki
|
|
|
187
189
|
# `{ name => value }` — a checkbox its checked state, a multi-select
|
|
188
190
|
# its selected values — and a submitted form contributes its FormData.
|
|
189
191
|
#
|
|
192
|
+
# `event: :popstate` goes on the island root and answers Back and
|
|
193
|
+
# Forward over the entries `transmit_url(history: :push)` made; the
|
|
194
|
+
# payload is the popped URL's query.
|
|
195
|
+
#
|
|
190
196
|
# Everything else is a per-control modifier, kept out of the token
|
|
191
197
|
# grammar so the `->` left side stays purely "which event":
|
|
192
198
|
#
|
|
@@ -218,12 +224,14 @@ module Hibiki
|
|
|
218
224
|
# works in both worlds).
|
|
219
225
|
def on(action, event: :click, with: nil, debounce: nil, confirm: nil, reset: nil,
|
|
220
226
|
fallback: nil)
|
|
221
|
-
|
|
227
|
+
extra = Modifiers.chain(action, with:, fallback:)
|
|
228
|
+
action = extra.empty? ? Helpers.event_name(action) : Modifiers::CHAIN
|
|
222
229
|
events = Array(event).map { Helpers.event(it) }
|
|
223
230
|
debounce = DEFAULT_INPUT_DEBOUNCE if debounce.nil? && events.include?("input")
|
|
231
|
+
Modifiers.popstate!(debounce:, fallback:) if events.include?("popstate")
|
|
224
232
|
tokens = events.map { "#{it}->#{action}" }.join(" ")
|
|
225
233
|
{ data: { hibiki_on: tokens }
|
|
226
|
-
.merge(
|
|
234
|
+
.merge(Modifiers.attributes(with:, debounce:, confirm:, reset:, fallback:), extra) }
|
|
227
235
|
end
|
|
228
236
|
|
|
229
237
|
# Placeholder for a single reactive value: `<%= reactive :doubled, 0 %>`
|
|
@@ -273,6 +281,30 @@ module Hibiki
|
|
|
273
281
|
{ data: { hibiki_props: JSON.generate(wanted) } }
|
|
274
282
|
end
|
|
275
283
|
|
|
284
|
+
# Commands the browser runs on the event itself, with no round trip:
|
|
285
|
+
#
|
|
286
|
+
# tag.button "Menu", **on(js.toggle("#menu"))
|
|
287
|
+
# tag.button "Cancel", **on(js.hide("#panel").push(:cancel))
|
|
288
|
+
#
|
|
289
|
+
# Commands lists them. `on` takes the list where it takes an action
|
|
290
|
+
# name; `confirm:` and `debounce:` then apply to the whole list, and
|
|
291
|
+
# the event's default is prevented. The page must import
|
|
292
|
+
# "hibiki-rails/commands". What a command changes lasts until the
|
|
293
|
+
# server next renders that element, so state the server should know
|
|
294
|
+
# belongs in a signal.
|
|
295
|
+
def js = Commands.new
|
|
296
|
+
|
|
297
|
+
# Open or close a <dialog> as the server's state says:
|
|
298
|
+
#
|
|
299
|
+
# tag.dialog id: "editor", **modal(open: form.editing?)
|
|
300
|
+
#
|
|
301
|
+
# After each render the client calls showModal() or close() to match.
|
|
302
|
+
# Render it by morph where you can: a replaced dialog is a new element
|
|
303
|
+
# and opens again. For a dialog the server need not know about, use
|
|
304
|
+
# `js.show_modal` and `js.close_dialog` instead, not both on one.
|
|
305
|
+
# `on(:cancel, event: :cancel)` on the dialog hears Escape.
|
|
306
|
+
def modal(open:) = { data: { hibiki_modal: open ? "true" : "false" } }
|
|
307
|
+
|
|
276
308
|
# Put the cursor in this element when a render opens it:
|
|
277
309
|
#
|
|
278
310
|
# tag.input name: "title", value: form.title, **focus(select: true)
|
|
@@ -305,20 +337,6 @@ module Hibiki
|
|
|
305
337
|
def island_stream(channel, cid, content)
|
|
306
338
|
safe_join([turbo_stream_from(channel.channel_name, cid), content])
|
|
307
339
|
end
|
|
308
|
-
|
|
309
|
-
# #on's per-control modifiers, kept out of the token grammar. Each is
|
|
310
|
-
# omitted when it matches the client's own default, so the common call
|
|
311
|
-
# still stamps exactly one attribute.
|
|
312
|
-
def on_modifiers(with:, debounce:, confirm:, reset:, fallback:)
|
|
313
|
-
{
|
|
314
|
-
hibiki_with: (JSON.generate(with) unless with.nil?),
|
|
315
|
-
# nonzero? returns the integer itself, so 0 compacts away.
|
|
316
|
-
hibiki_debounce: (Integer(debounce).nonzero? if debounce),
|
|
317
|
-
hibiki_confirm: confirm&.to_s,
|
|
318
|
-
hibiki_reset: ("false" if reset == false),
|
|
319
|
-
hibiki_fallback: ("true" if fallback)
|
|
320
|
-
}.compact
|
|
321
|
-
end
|
|
322
340
|
end
|
|
323
341
|
end
|
|
324
342
|
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Hibiki
|
|
6
|
+
module Rails
|
|
7
|
+
# Helpers#on's per-control modifiers, kept out of the token grammar.
|
|
8
|
+
module Modifiers
|
|
9
|
+
# The attributes beside hibiki_on. Each is omitted when it matches the
|
|
10
|
+
# client's own default, so the common call still stamps exactly one.
|
|
11
|
+
def self.attributes(with:, debounce:, confirm:, reset:, fallback:)
|
|
12
|
+
{
|
|
13
|
+
hibiki_with: (JSON.generate(with) unless with.nil?),
|
|
14
|
+
# nonzero? returns the integer itself, so 0 compacts away.
|
|
15
|
+
hibiki_debounce: (Integer(debounce).nonzero? if debounce),
|
|
16
|
+
hibiki_confirm: confirm&.to_s,
|
|
17
|
+
hibiki_reset: ("false" if reset == false),
|
|
18
|
+
hibiki_fallback: ("true" if fallback)
|
|
19
|
+
}.compact
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# What stands in for the action name in a token whose control runs a
|
|
23
|
+
# command list. No action can be named this.
|
|
24
|
+
CHAIN = "*"
|
|
25
|
+
|
|
26
|
+
# The attribute a command list adds, or nothing for an action name.
|
|
27
|
+
def self.chain(action, with:, fallback:)
|
|
28
|
+
return {} unless action.is_a?(Commands)
|
|
29
|
+
raise ArgumentError, "on(js) needs at least one command" if action.empty?
|
|
30
|
+
raise ArgumentError, "with: belongs on the command: js.push(:action, with: ...)" unless with.nil?
|
|
31
|
+
raise ArgumentError, "fallback: needs a push in the commands" if fallback && !action.push?
|
|
32
|
+
|
|
33
|
+
{ hibiki_do: action.to_json }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Back is not a gesture on a control: it has nothing to wait for and
|
|
37
|
+
# no native destination to fall back to.
|
|
38
|
+
def self.popstate!(**modifiers)
|
|
39
|
+
given = modifiers.compact.keys
|
|
40
|
+
raise ArgumentError, "a popstate event takes no #{given.join(' or ')}" if given.any?
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
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.20.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-commands.js
|
|
79
80
|
- app/assets/javascripts/hibiki-motion.js
|
|
80
81
|
- app/assets/javascripts/hibiki.js
|
|
81
82
|
- config/importmap.rb
|
|
@@ -182,6 +183,7 @@ files:
|
|
|
182
183
|
- lib/hibiki/rails.rb
|
|
183
184
|
- lib/hibiki/rails/broadcasts.rb
|
|
184
185
|
- lib/hibiki/rails/channel.rb
|
|
186
|
+
- lib/hibiki/rails/commands.rb
|
|
185
187
|
- lib/hibiki/rails/debounce.rb
|
|
186
188
|
- lib/hibiki/rails/dom_props.rb
|
|
187
189
|
- lib/hibiki/rails/engine.rb
|
|
@@ -189,6 +191,7 @@ files:
|
|
|
189
191
|
- lib/hibiki/rails/helpers.rb
|
|
190
192
|
- lib/hibiki/rails/instrumentation.rb
|
|
191
193
|
- lib/hibiki/rails/keys.rb
|
|
194
|
+
- lib/hibiki/rails/modifiers.rb
|
|
192
195
|
- lib/hibiki/rails/nested_actions.rb
|
|
193
196
|
- lib/hibiki/rails/reactive_form.rb
|
|
194
197
|
- lib/hibiki/rails/registry.rb
|