hibiki_rails 0.19.0 → 0.20.1
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 +94 -0
- data/app/assets/javascripts/hibiki-commands.js +105 -0
- data/app/assets/javascripts/hibiki.js +121 -26
- data/config/importmap.rb +3 -0
- data/lib/generators/hibiki/rails/install/install_generator.rb +14 -1
- data/lib/hibiki/rails/action_order.rb +96 -0
- data/lib/hibiki/rails/channel.rb +29 -10
- data/lib/hibiki/rails/commands.rb +130 -0
- data/lib/hibiki/rails/helpers.rb +28 -2
- data/lib/hibiki/rails/modifiers.rb +14 -0
- data/lib/hibiki/rails/version.rb +1 -1
- data/lib/hibiki/rails.rb +1 -0
- 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: fcb75b30dd2f92f65a4581f6b26ca93f92ef7665c333139b3b6b1f839db70943
|
|
4
|
+
data.tar.gz: 4de446211377d8a95301b0fc5cd9d5361d6ce76489da9bb224711f90a29d51a2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b4c546401703167cc3d2f6ed5690a0b93d95f631fd311d3cc1883a51a6b29365a9a631251690fc351708fba80af4e73fd303034c92f5f7192c6148da26aea644
|
|
7
|
+
data.tar.gz: 3657421c216fcc9f44c2a7d6d7a14a44e0737d9029e37f4e2a0652c31b4b486aba25f4888c045df32d2c88ba1f55f0de4b18cdaf43df29dd68b1fb89cb8b2887
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,100 @@ 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.1 — 2026-10-12
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
**A channel runs its actions in the order the browser sent them.** Action
|
|
12
|
+
Cable hands each incoming message to a worker pool, so two actions sent back
|
|
13
|
+
to back could run swapped. The client sends such a pair whenever a visitor
|
|
14
|
+
types in a debounced field and then clicks or presses Enter at once: the
|
|
15
|
+
typed value and the action leave in the same instant. When they swapped, the
|
|
16
|
+
action ran before the value arrived, so a save could miss the last thing
|
|
17
|
+
typed or be refused.
|
|
18
|
+
|
|
19
|
+
- The channel now runs each subscription's actions by the number the client
|
|
20
|
+
gives them. An action that arrives ahead of its turn waits for the ones
|
|
21
|
+
before it, for at most 100 ms.
|
|
22
|
+
- Update the gem and the npm package together. A client older than 0.20.1
|
|
23
|
+
still works with the new gem, but its first action after a reconnect waits
|
|
24
|
+
100 ms.
|
|
25
|
+
- Nothing changes in your channels or views.
|
|
26
|
+
- An unsubscribe is not numbered, and can still overtake an action sent just
|
|
27
|
+
before the page is left.
|
|
28
|
+
|
|
29
|
+
## 0.20.0 — 2026-10-12
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
**Commands.** `js` builds a list of things the browser does on an event,
|
|
34
|
+
with no round trip. `on` takes the list where it takes an action name.
|
|
35
|
+
|
|
36
|
+
```erb
|
|
37
|
+
<%= tag.button "Menu", **on(js.toggle("#menu")) %>
|
|
38
|
+
<%= tag.button "Cancel", **on(js.hide("#panel").push(:cancel)) %>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
| Command | What it does |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `show`, `hide`, `toggle` | writes the `hidden` attribute |
|
|
44
|
+
| `add_class`, `remove_class`, `toggle_class` | one or more class names |
|
|
45
|
+
| `set_attribute`, `remove_attribute`, `toggle_attribute` | an attribute by name |
|
|
46
|
+
| `focus`, `focus_first` | focuses the target, or the first control inside it |
|
|
47
|
+
| `dispatch` | fires a `CustomEvent`, with `detail:` and `bubbles:` |
|
|
48
|
+
| `push` | performs a channel action, with `with:` as its payload |
|
|
49
|
+
| `show_modal`, `close_dialog` | a `<dialog>`'s own methods |
|
|
50
|
+
|
|
51
|
+
- A target is a CSS selector for the whole document, `{ inner: selector }`
|
|
52
|
+
for inside the control, or `{ closest: selector }` for its nearest
|
|
53
|
+
ancestor. With no target a command acts on the control. `hide`, `show`,
|
|
54
|
+
`toggle`, `focus`, `focus_first`, `show_modal` and `close_dialog` take the
|
|
55
|
+
target as their argument; the others take it as `to:`.
|
|
56
|
+
- The commands run in order and do not wait for the server.
|
|
57
|
+
- `confirm:` and `debounce:` on `on` apply to the whole list. `with:` goes on
|
|
58
|
+
`push`. `fallback: true` needs a `push` in the list.
|
|
59
|
+
- A list prevents the event's default.
|
|
60
|
+
- A command's change lasts until the server next renders that element. Keep
|
|
61
|
+
state the server should know in a signal.
|
|
62
|
+
- Commands run only on a control inside an island.
|
|
63
|
+
- `set_attribute` and its siblings refuse `on*`, `style`, `srcdoc`,
|
|
64
|
+
`data-hibiki-*`, `data-controller` and `data-action`.
|
|
65
|
+
|
|
66
|
+
**The `hibiki-rails/commands` module.** Commands need one import, beside
|
|
67
|
+
your controllers:
|
|
68
|
+
|
|
69
|
+
```js
|
|
70
|
+
import "hibiki-rails/commands"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`hibiki:rails:install` adds it to `app/javascript/application.js`. An
|
|
74
|
+
existing app adds the line by hand, or runs the generator again. Without it
|
|
75
|
+
a command list does nothing, and the client says so once in the console.
|
|
76
|
+
|
|
77
|
+
**Dialogs the server opens.** `modal(open:)` on a `<dialog>` makes the
|
|
78
|
+
channel's state decide whether it is open. After each render the client
|
|
79
|
+
calls `showModal()` or `close()` to match.
|
|
80
|
+
|
|
81
|
+
```erb
|
|
82
|
+
<%= tag.dialog id: "editor", **on(:cancel, event: :cancel).deep_merge(modal(open: form.editing?)) do %>
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
- Use `modal` for a dialog the server must know about, such as a form it may
|
|
86
|
+
refuse to save. Use `js.show_modal` and `js.close_dialog` for one it need
|
|
87
|
+
not. Do not put both on one dialog.
|
|
88
|
+
- `on` now hears a dialog's `cancel` and `close` events. `cancel` is Escape;
|
|
89
|
+
`close` also follows `close_dialog` and a close the server asked for.
|
|
90
|
+
- A morph no longer removes the `open` attribute from a dialog inside an
|
|
91
|
+
island.
|
|
92
|
+
- Keep what is typed in the dialog out of the effect that renders it, or
|
|
93
|
+
each keystroke redraws it.
|
|
94
|
+
|
|
95
|
+
### Changed
|
|
96
|
+
|
|
97
|
+
- A `focus` site inside a closed dialog does not count as on the page. It
|
|
98
|
+
takes focus when a render of this tab opens the dialog, and focus returns
|
|
99
|
+
to the control that opened it when the dialog closes.
|
|
100
|
+
|
|
7
101
|
## 0.19.0 — 2026-10-12
|
|
8
102
|
|
|
9
103
|
### 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
|
|
@@ -208,6 +213,36 @@ window.addEventListener("popstate", (event) => {
|
|
|
208
213
|
if (key && !pushers.get(key)?.pop?.(event)) window.location.reload()
|
|
209
214
|
})
|
|
210
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
|
+
|
|
211
246
|
// The generic island CONTAINING element, or undefined. The walk performOn
|
|
212
247
|
// uses, exported for the motion module's `own` policy — which reads the
|
|
213
248
|
// island's trip records, so a ChannelController subclass (not in the map)
|
|
@@ -387,10 +422,19 @@ export class ChannelController extends Controller {
|
|
|
387
422
|
// undefined when the payload went nowhere — the socket turned out to
|
|
388
423
|
// be closed, or the island was already offline — and the caller owns
|
|
389
424
|
// recovery.
|
|
425
|
+
//
|
|
426
|
+
// The server runs a subscription's actions in seq order (Action Cable's
|
|
427
|
+
// worker pool can swap two sent back to back), so the numbers it sees
|
|
428
|
+
// must run from 1 with none missing: an action dropped in the offline gap
|
|
429
|
+
// takes no number, and linkClosed starts the count again for the fresh
|
|
430
|
+
// subscription a reconnect makes.
|
|
390
431
|
perform(action, payload = {}) {
|
|
432
|
+
delete payload.hbt
|
|
433
|
+
// The offline gap: dropped, and the return says so — a truthy seq here
|
|
434
|
+
// would tell a public caller a repaint is coming when none is.
|
|
435
|
+
if (!this.subscribed && this.connectedOnce) return undefined
|
|
391
436
|
const seq = ++this.seq
|
|
392
437
|
payload.hbk = seq
|
|
393
|
-
delete payload.hbt
|
|
394
438
|
if (this.subscribed) {
|
|
395
439
|
this.sendTrips(payload)
|
|
396
440
|
this.beginBusy(seq, action)
|
|
@@ -418,15 +462,10 @@ export class ChannelController extends Controller {
|
|
|
418
462
|
// the socket dropped, and the island is stamped `offline` for the
|
|
419
463
|
// whole gap — the signal the connect window cannot give, where the
|
|
420
464
|
// page is painted and looks live.
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
return seq
|
|
426
|
-
}
|
|
427
|
-
// The offline gap: dropped, and the return says so — a truthy seq here
|
|
428
|
-
// would tell a public caller a repaint is coming when none is.
|
|
429
|
-
return undefined
|
|
465
|
+
this.sendTrips(payload)
|
|
466
|
+
this.beginBusy(seq, action)
|
|
467
|
+
this.queued.push([action, payload])
|
|
468
|
+
return seq
|
|
430
469
|
}
|
|
431
470
|
|
|
432
471
|
// ActionCable's `connected`, i.e. the server confirmed the subscription.
|
|
@@ -449,6 +488,8 @@ export class ChannelController extends Controller {
|
|
|
449
488
|
// reason — their acks are never coming.
|
|
450
489
|
this.queued = []
|
|
451
490
|
this.settleAll()
|
|
491
|
+
// The fresh subscription expects its actions numbered from 1.
|
|
492
|
+
this.seq = 0
|
|
452
493
|
}
|
|
453
494
|
|
|
454
495
|
setState(state) {
|
|
@@ -911,6 +952,9 @@ export default class HibikiController extends ChannelController {
|
|
|
911
952
|
for (const site of this.element.querySelectorAll("[data-hibiki-props]")) {
|
|
912
953
|
applyProps(site)
|
|
913
954
|
}
|
|
955
|
+
for (const dialog of this.element.querySelectorAll("[data-hibiki-modal]")) {
|
|
956
|
+
applyModal(dialog)
|
|
957
|
+
}
|
|
914
958
|
|
|
915
959
|
// Root-scoped delegation (bound to the island, not document): controls
|
|
916
960
|
// inside server-replaced fragments keep working with no rebinding.
|
|
@@ -920,6 +964,13 @@ export default class HibikiController extends ChannelController {
|
|
|
920
964
|
this.element.addEventListener(type, handler)
|
|
921
965
|
return [type, handler]
|
|
922
966
|
})
|
|
967
|
+
// A dialog's `cancel` (Escape, before it closes) and `close` do not
|
|
968
|
+
// bubble, so they are heard on the way down.
|
|
969
|
+
for (const type of ["cancel", "close"]) {
|
|
970
|
+
const handler = (event) => this.forward(event)
|
|
971
|
+
this.element.addEventListener(type, handler, true)
|
|
972
|
+
this.listeners.push([type, handler, true])
|
|
973
|
+
}
|
|
923
974
|
// Keys take their own path: a key names its control by a token, not by
|
|
924
975
|
// being the nearest one.
|
|
925
976
|
const keydown = (event) => this.key(event)
|
|
@@ -941,7 +992,7 @@ export default class HibikiController extends ChannelController {
|
|
|
941
992
|
// page's own marks already seen.
|
|
942
993
|
this.marks = new Map()
|
|
943
994
|
for (const site of this.element.querySelectorAll("[data-hibiki-focus]")) {
|
|
944
|
-
if (
|
|
995
|
+
if (this.marked(site)) this.marks.set(site, undefined)
|
|
945
996
|
}
|
|
946
997
|
|
|
947
998
|
// `visible` is not a DOM event, so it needs its own observer beside the
|
|
@@ -1008,8 +1059,8 @@ export default class HibikiController extends ChannelController {
|
|
|
1008
1059
|
|
|
1009
1060
|
disconnect() {
|
|
1010
1061
|
islands.delete(this.element)
|
|
1011
|
-
for (const [type, handler] of this.listeners) {
|
|
1012
|
-
this.element.removeEventListener(type, handler)
|
|
1062
|
+
for (const [type, handler, capture] of this.listeners) {
|
|
1063
|
+
this.element.removeEventListener(type, handler, capture)
|
|
1013
1064
|
}
|
|
1014
1065
|
document.removeEventListener("turbo:before-stream-render", this.streamRender)
|
|
1015
1066
|
document.removeEventListener("turbo:before-render", this.pageBefore)
|
|
@@ -1158,8 +1209,11 @@ export default class HibikiController extends ChannelController {
|
|
|
1158
1209
|
if (self && index === 0) {
|
|
1159
1210
|
const twin = found(ids[0])
|
|
1160
1211
|
twin.focus()
|
|
1161
|
-
|
|
1162
|
-
|
|
1212
|
+
// It may not have taken it: a field of a dialog now closed.
|
|
1213
|
+
if (document.activeElement === twin) {
|
|
1214
|
+
if (start != null) twin.setSelectionRange?.(start, end)
|
|
1215
|
+
return
|
|
1216
|
+
}
|
|
1163
1217
|
}
|
|
1164
1218
|
const candidates = [opener && this.twin(opener)]
|
|
1165
1219
|
if (index === (self ? 1 : 0)) candidates.push(...found(ids[index]).querySelectorAll(FOCUSABLE))
|
|
@@ -1171,12 +1225,13 @@ export default class HibikiController extends ChannelController {
|
|
|
1171
1225
|
|
|
1172
1226
|
// The control a gesture came from, or the one a render has put in its
|
|
1173
1227
|
// place: the same id, else the same action and payload.
|
|
1174
|
-
twin({ element, id, on, payload }) {
|
|
1228
|
+
twin({ element, id, on, payload, ops }) {
|
|
1175
1229
|
if (element.isConnected) return element
|
|
1176
1230
|
const twin = id
|
|
1177
1231
|
? document.getElementById(id)
|
|
1178
1232
|
: [...this.element.querySelectorAll("[data-hibiki-on]")].find(
|
|
1179
|
-
(
|
|
1233
|
+
({ dataset }) =>
|
|
1234
|
+
dataset.hibikiOn === on && dataset.hibikiWith === payload && dataset.hibikiDo === ops
|
|
1180
1235
|
)
|
|
1181
1236
|
return twin?.closest(ISLAND) === this.element ? twin : null
|
|
1182
1237
|
}
|
|
@@ -1193,9 +1248,17 @@ export default class HibikiController extends ChannelController {
|
|
|
1193
1248
|
// a listener twice is a no-op too. Props need no ownership check: assigning one twice,
|
|
1194
1249
|
// from a nested island and its parent, lands the same value. Nor does
|
|
1195
1250
|
// the window listener, which finds nothing of its own to fire.
|
|
1251
|
+
// A focus site this island counts as on the page: its own, and not
|
|
1252
|
+
// inside a closed dialog. So a dialog's field is new on the render that
|
|
1253
|
+
// opens the dialog, though its markup was there all along, and gone on
|
|
1254
|
+
// the one that closes it, which is when focus goes back to the opener.
|
|
1255
|
+
marked(site) {
|
|
1256
|
+
return site.closest(ISLAND) === this.element && !site.closest("dialog:not([open])")
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1196
1259
|
scan({ own = false, control, trail } = {}) {
|
|
1197
1260
|
const found = this.element.querySelectorAll(
|
|
1198
|
-
`[data-hibiki-on*="visible->"], [data-hibiki-props], [data-hibiki-focus], ${HOTKEYED}`
|
|
1261
|
+
`[data-hibiki-on*="visible->"], [data-hibiki-props], [data-hibiki-focus], [data-hibiki-modal], ${HOTKEYED}`
|
|
1199
1262
|
)
|
|
1200
1263
|
let hotkeys = this.element.matches(HOTKEYED)
|
|
1201
1264
|
// A site is the one seen before when it is that element or, after a
|
|
@@ -1206,7 +1269,9 @@ export default class HibikiController extends ChannelController {
|
|
|
1206
1269
|
const ids = new Set()
|
|
1207
1270
|
let site
|
|
1208
1271
|
for (const element of found) {
|
|
1209
|
-
|
|
1272
|
+
// First: a focus site inside the dialog is on the page once it opens.
|
|
1273
|
+
if (element.hasAttribute("data-hibiki-modal")) applyModal(element)
|
|
1274
|
+
if (element.hasAttribute("data-hibiki-focus") && this.marked(element)) {
|
|
1210
1275
|
marks.set(element, this.marks.get(element) ?? seen.get(element.id))
|
|
1211
1276
|
if (element.id) ids.add(element.id)
|
|
1212
1277
|
if (!this.marks.has(element) && !seen.has(element.id)) site ??= element
|
|
@@ -1232,7 +1297,13 @@ export default class HibikiController extends ChannelController {
|
|
|
1232
1297
|
if (own && site && !this.unsent.has(trail?.element)) {
|
|
1233
1298
|
if (control) {
|
|
1234
1299
|
const { id, dataset } = control
|
|
1235
|
-
marks.set(site, {
|
|
1300
|
+
marks.set(site, {
|
|
1301
|
+
element: control,
|
|
1302
|
+
id,
|
|
1303
|
+
on: dataset.hibikiOn,
|
|
1304
|
+
payload: dataset.hibikiWith,
|
|
1305
|
+
ops: dataset.hibikiDo
|
|
1306
|
+
})
|
|
1236
1307
|
}
|
|
1237
1308
|
const take = () => {
|
|
1238
1309
|
site.focus()
|
|
@@ -1357,7 +1428,9 @@ export default class HibikiController extends ChannelController {
|
|
|
1357
1428
|
// Before the confirm, not after: declining must not let the form (or
|
|
1358
1429
|
// a fallback control's navigation) proceed. Optional call because the
|
|
1359
1430
|
// `visible` pseudo-event arrives as a plain object.
|
|
1360
|
-
|
|
1431
|
+
// A command list is the control's whole behaviour, so its default goes.
|
|
1432
|
+
const listed = action === "*"
|
|
1433
|
+
if (event.type === "submit" || fallback || listed) event.preventDefault?.()
|
|
1361
1434
|
|
|
1362
1435
|
const message = control.dataset.hibikiConfirm
|
|
1363
1436
|
if (message && !window.confirm(message)) return
|
|
@@ -1366,7 +1439,7 @@ export default class HibikiController extends ChannelController {
|
|
|
1366
1439
|
// input sends what the user finished typing rather than the first
|
|
1367
1440
|
// keystroke that started the timer.
|
|
1368
1441
|
const wait = Number(control.dataset.hibikiDebounce)
|
|
1369
|
-
const fire = () => this.send(control, event, action)
|
|
1442
|
+
const fire = () => (listed ? this.command(control, event) : this.send(control, event, action))
|
|
1370
1443
|
if (wait > 0) {
|
|
1371
1444
|
const field = event.target
|
|
1372
1445
|
if (event.type !== "keydown" && typed(field)) {
|
|
@@ -1411,10 +1484,32 @@ export default class HibikiController extends ChannelController {
|
|
|
1411
1484
|
}
|
|
1412
1485
|
}
|
|
1413
1486
|
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1487
|
+
// The control's command list, handed to the command module with the one
|
|
1488
|
+
// thing only this file can do: send an action as the control's own.
|
|
1489
|
+
command(control, event) {
|
|
1490
|
+
if (!commands) {
|
|
1491
|
+
if (!warned) console.warn('hibiki: a control has commands, but "hibiki-rails/commands" is not imported')
|
|
1492
|
+
warned = true
|
|
1493
|
+
return
|
|
1494
|
+
}
|
|
1495
|
+
let ops
|
|
1496
|
+
try {
|
|
1497
|
+
ops = JSON.parse(control.dataset.hibikiDo)
|
|
1498
|
+
} catch {
|
|
1499
|
+
return
|
|
1500
|
+
}
|
|
1501
|
+
if (!Array.isArray(ops)) return
|
|
1502
|
+
commands(ops, control, (action, payload) => this.send(control, event, action, payload))
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
// `given` is a pushed command's payload, where a plain action reads the
|
|
1506
|
+
// control's own.
|
|
1507
|
+
send(control, event, action, given) {
|
|
1508
|
+
const payload = given
|
|
1509
|
+
? { ...given }
|
|
1510
|
+
: control.dataset.hibikiWith
|
|
1511
|
+
? JSON.parse(control.dataset.hibikiWith)
|
|
1512
|
+
: {}
|
|
1418
1513
|
if (event.type === "submit") {
|
|
1419
1514
|
Object.assign(payload, formPayload(control))
|
|
1420
1515
|
} 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
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent/scheduled_task"
|
|
4
|
+
|
|
5
|
+
module Hibiki
|
|
6
|
+
module Rails
|
|
7
|
+
# Puts one subscription's incoming actions back in the order the client
|
|
8
|
+
# sent them. ActionCable hands every websocket message to a worker pool
|
|
9
|
+
# (Connection#receive is send_async), so two actions sent back to back
|
|
10
|
+
# can reach Channel#perform_action in either order — and the client's
|
|
11
|
+
# flush sends a typed field and the click after it in one tick.
|
|
12
|
+
#
|
|
13
|
+
# The client numbers its actions under `hbk`, from 1 per subscription
|
|
14
|
+
# with no gaps. #submit calls each job when its number comes up: one
|
|
15
|
+
# ahead of its turn is held until the missing ones arrive, or until
|
|
16
|
+
# GRACE runs out for that gap (a client older than this numbers on
|
|
17
|
+
# across a reconnect, so its first action there waits one grace). A
|
|
18
|
+
# number whose turn has passed, a repeat, and anything that is not an
|
|
19
|
+
# Integer run at once.
|
|
20
|
+
#
|
|
21
|
+
# Jobs run under the lock, on whichever thread releases them, so they
|
|
22
|
+
# must only enqueue: the order of the calls is the order of the queue.
|
|
23
|
+
# Like Registry, this is glue touched from many cable threads, not the
|
|
24
|
+
# graph; a lock is the right tool here.
|
|
25
|
+
class ActionOrder
|
|
26
|
+
# Seconds one gap is waited for. A websocket does not lose messages,
|
|
27
|
+
# so only a number that was never sent waits it out.
|
|
28
|
+
GRACE = 0.1
|
|
29
|
+
|
|
30
|
+
def initialize
|
|
31
|
+
@lock = Mutex.new
|
|
32
|
+
@due = 1
|
|
33
|
+
@held = {}
|
|
34
|
+
@closed = false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def submit(seq, &job)
|
|
38
|
+
return job.call unless seq.is_a?(Integer)
|
|
39
|
+
|
|
40
|
+
@lock.synchronize do
|
|
41
|
+
next job.call if @closed || seq < @due || @held.key?(seq)
|
|
42
|
+
|
|
43
|
+
@held[seq] = job
|
|
44
|
+
release
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Run what is held, in order, and let everything after straight
|
|
49
|
+
# through. Channel#unsubscribed calls it ahead of the dispose.
|
|
50
|
+
def close
|
|
51
|
+
@lock.synchronize do
|
|
52
|
+
@closed = true
|
|
53
|
+
disarm
|
|
54
|
+
@held.sort.each { |_, job| job.call }
|
|
55
|
+
@held.clear
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def release
|
|
62
|
+
while (job = @held.delete(@due))
|
|
63
|
+
@due += 1
|
|
64
|
+
job.call
|
|
65
|
+
end
|
|
66
|
+
watch
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# One timer per gap: more arrivals behind the same gap do not restart
|
|
70
|
+
# it. `@waiting` is the number waited for, which is also what tells a
|
|
71
|
+
# timer that fires late it is stale.
|
|
72
|
+
def watch
|
|
73
|
+
return disarm if @held.empty?
|
|
74
|
+
return if @waiting == @due
|
|
75
|
+
|
|
76
|
+
disarm
|
|
77
|
+
waiting = @waiting = @due
|
|
78
|
+
@timer = Concurrent::ScheduledTask.execute(GRACE) { give_up(waiting) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def disarm
|
|
82
|
+
@timer&.cancel
|
|
83
|
+
@timer = @waiting = nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def give_up(waiting)
|
|
87
|
+
@lock.synchronize do
|
|
88
|
+
next unless @waiting == waiting
|
|
89
|
+
|
|
90
|
+
@due = @held.keys.min
|
|
91
|
+
release
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
data/lib/hibiki/rails/channel.rb
CHANGED
|
@@ -93,14 +93,17 @@ module Hibiki
|
|
|
93
93
|
# `hbt` is the third reserved key: the trips the browser timed since
|
|
94
94
|
# its last action (see Instrumentation#trips_from). Deleted whether or
|
|
95
95
|
# not anyone listens, for the same reason as `hbk`.
|
|
96
|
+
#
|
|
97
|
+
# ActionCable runs this on its worker pool, several at once and in no
|
|
98
|
+
# order, so the post waits for the seq's turn (see ActionOrder). An
|
|
99
|
+
# action with no seq takes no turn. `asked` is read on arrival: a held
|
|
100
|
+
# action's wait is part of its `action` event.
|
|
96
101
|
def perform_action(data)
|
|
97
102
|
seq = data.delete("hbk")
|
|
98
103
|
trips = trips_from(data.delete("hbt"))
|
|
99
104
|
asked = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
100
105
|
|
|
101
|
-
|
|
102
|
-
# down; drop the action rather than blow up the cable thread.
|
|
103
|
-
posted = @__hibiki_actor&.post do
|
|
106
|
+
post_in_turn(seq) do
|
|
104
107
|
trips.each { instrument("trip", it) }
|
|
105
108
|
instrument_action(data, asked) { Hibiki.batch { super(data) } }
|
|
106
109
|
ensure
|
|
@@ -111,13 +114,6 @@ module Hibiki
|
|
|
111
114
|
# in exactly the case the user most needs it to stop.
|
|
112
115
|
transmit({ ack: seq }) if seq
|
|
113
116
|
end
|
|
114
|
-
|
|
115
|
-
# post yields nil (no actor) or false (queue closed): the block
|
|
116
|
-
# never ran, so neither did its ensure. `dropped` is also what lets
|
|
117
|
-
# the client tell "late" from "never" — nothing is coming for this
|
|
118
|
-
# seq, so it settles immediately rather than waiting out the grace
|
|
119
|
-
# window.
|
|
120
|
-
transmit({ ack: seq, dropped: true }) if seq && !posted
|
|
121
117
|
end
|
|
122
118
|
|
|
123
119
|
private
|
|
@@ -126,6 +122,7 @@ module Hibiki
|
|
|
126
122
|
super
|
|
127
123
|
return reject if cid.blank?
|
|
128
124
|
|
|
125
|
+
@__hibiki_order = ActionOrder.new
|
|
129
126
|
@__hibiki_actor = build_graph_actor
|
|
130
127
|
# The block runs owned + untracked (Hibiki.root); the effects it
|
|
131
128
|
# creates do their dependency-collecting first run right here, on
|
|
@@ -144,10 +141,32 @@ module Hibiki
|
|
|
144
141
|
return unless @__hibiki_actor
|
|
145
142
|
|
|
146
143
|
Hibiki::Rails.registry.unregister(self)
|
|
144
|
+
# Held actions run first: the dispose must be the last job posted.
|
|
145
|
+
@__hibiki_order.close
|
|
147
146
|
@__hibiki_actor.post { instrument("unsubscribe") { @__hibiki_root.dispose } }
|
|
148
147
|
@__hibiki_actor.stop
|
|
149
148
|
end
|
|
150
149
|
|
|
150
|
+
# Post the job to the graph when the action numbered `seq` is due. A
|
|
151
|
+
# rejected subscription has no order and nothing to keep in order.
|
|
152
|
+
#
|
|
153
|
+
# A nil actor means the subscription was rejected or already torn
|
|
154
|
+
# down; drop the action rather than blow up the cable thread. post
|
|
155
|
+
# yields nil (no actor) or false (queue closed): the job never ran, so
|
|
156
|
+
# neither did its ensure. `dropped` is also what lets the client tell
|
|
157
|
+
# "late" from "never" — nothing is coming for this seq, so it settles
|
|
158
|
+
# immediately rather than waiting out the grace window.
|
|
159
|
+
def post_in_turn(seq, &job)
|
|
160
|
+
return post_or_drop(seq, job) unless @__hibiki_order
|
|
161
|
+
|
|
162
|
+
@__hibiki_order.submit(seq) { post_or_drop(seq, job) }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def post_or_drop(seq, job)
|
|
166
|
+
posted = @__hibiki_actor&.post(&job)
|
|
167
|
+
transmit({ ack: seq, dropped: true }) if seq && !posted
|
|
168
|
+
end
|
|
169
|
+
|
|
151
170
|
# Build the signal graph: create states/deriveds/effects as instance
|
|
152
171
|
# variables so actions can reach them. Runs once, on the graph's
|
|
153
172
|
# thread, inside Hibiki.root.
|
|
@@ -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
|
@@ -7,6 +7,7 @@ require "securerandom"
|
|
|
7
7
|
require_relative "dom_props"
|
|
8
8
|
require_relative "keys"
|
|
9
9
|
require_relative "modifiers"
|
|
10
|
+
require_relative "commands"
|
|
10
11
|
|
|
11
12
|
module Hibiki
|
|
12
13
|
module Rails
|
|
@@ -223,13 +224,14 @@ module Hibiki
|
|
|
223
224
|
# works in both worlds).
|
|
224
225
|
def on(action, event: :click, with: nil, debounce: nil, confirm: nil, reset: nil,
|
|
225
226
|
fallback: nil)
|
|
226
|
-
|
|
227
|
+
extra = Modifiers.chain(action, with:, fallback:)
|
|
228
|
+
action = extra.empty? ? Helpers.event_name(action) : Modifiers::CHAIN
|
|
227
229
|
events = Array(event).map { Helpers.event(it) }
|
|
228
230
|
debounce = DEFAULT_INPUT_DEBOUNCE if debounce.nil? && events.include?("input")
|
|
229
231
|
Modifiers.popstate!(debounce:, fallback:) if events.include?("popstate")
|
|
230
232
|
tokens = events.map { "#{it}->#{action}" }.join(" ")
|
|
231
233
|
{ data: { hibiki_on: tokens }
|
|
232
|
-
.merge(Modifiers.attributes(with:, debounce:, confirm:, reset:, fallback:)) }
|
|
234
|
+
.merge(Modifiers.attributes(with:, debounce:, confirm:, reset:, fallback:), extra) }
|
|
233
235
|
end
|
|
234
236
|
|
|
235
237
|
# Placeholder for a single reactive value: `<%= reactive :doubled, 0 %>`
|
|
@@ -279,6 +281,30 @@ module Hibiki
|
|
|
279
281
|
{ data: { hibiki_props: JSON.generate(wanted) } }
|
|
280
282
|
end
|
|
281
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
|
+
|
|
282
308
|
# Put the cursor in this element when a render opens it:
|
|
283
309
|
#
|
|
284
310
|
# tag.input name: "title", value: form.title, **focus(select: true)
|
|
@@ -19,6 +19,20 @@ module Hibiki
|
|
|
19
19
|
}.compact
|
|
20
20
|
end
|
|
21
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
|
+
|
|
22
36
|
# Back is not a gesture on a control: it has nothing to wait for and
|
|
23
37
|
# no native destination to fall back to.
|
|
24
38
|
def self.popstate!(**modifiers)
|
data/lib/hibiki/rails/version.rb
CHANGED
data/lib/hibiki/rails.rb
CHANGED
|
@@ -67,6 +67,7 @@ end
|
|
|
67
67
|
require_relative "rails/version"
|
|
68
68
|
require_relative "rails/registry"
|
|
69
69
|
require_relative "rails/graph_actor"
|
|
70
|
+
require_relative "rails/action_order"
|
|
70
71
|
require_relative "rails/debounce"
|
|
71
72
|
require_relative "rails/instrumentation"
|
|
72
73
|
require_relative "rails/broadcasts"
|
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.1
|
|
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
|
|
@@ -180,8 +181,10 @@ files:
|
|
|
180
181
|
- lib/generators/hibiki/rails/upload_field_helpers.rb
|
|
181
182
|
- lib/generators/hibiki/rails/upload_field_injections.rb
|
|
182
183
|
- lib/hibiki/rails.rb
|
|
184
|
+
- lib/hibiki/rails/action_order.rb
|
|
183
185
|
- lib/hibiki/rails/broadcasts.rb
|
|
184
186
|
- lib/hibiki/rails/channel.rb
|
|
187
|
+
- lib/hibiki/rails/commands.rb
|
|
185
188
|
- lib/hibiki/rails/debounce.rb
|
|
186
189
|
- lib/hibiki/rails/dom_props.rb
|
|
187
190
|
- lib/hibiki/rails/engine.rb
|