hibiki_rails 0.15.0 → 0.17.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 +126 -0
- data/app/assets/javascripts/hibiki-motion.js +4 -0
- data/app/assets/javascripts/hibiki.js +438 -25
- data/lib/hibiki/rails/dom_props.rb +33 -0
- data/lib/hibiki/rails/helpers.rb +61 -6
- data/lib/hibiki/rails/keys.rb +73 -0
- data/lib/hibiki/rails/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fbcb87c658d3e83cc6c10f216a69e78266878aa8396ed9662e3f06d8c6213027
|
|
4
|
+
data.tar.gz: d6a66d149293d26e43edf4cd31ec20eb5886cc0d3db2b935b5b0c3c333965292
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0e027bd60c62abdf4d269b94cd38f0e008889b42d205b9f2d98d737f1455acd60f467cf9951a5a35f77bbc3ee02120b8e5f6397fc43dc9b7dc7842d91c73372e
|
|
7
|
+
data.tar.gz: 28a6058a2a235ef23e02d52b5dbdf7177f97b6ba96971d9c72ea0d0302d6eaf380854b2e283069177b2038a06a2d45af540bf3b929a62f53baa7144589c33bc3
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,132 @@ 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.17.0 — 2026-10-08
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
**Keys in `on`.** `event:` takes `keydown.<key>`, so a form's Escape or a
|
|
12
|
+
list's arrow keys reach the channel with no JavaScript in the app.
|
|
13
|
+
|
|
14
|
+
```erb
|
|
15
|
+
<%= tag.div(**on(:cancel, event: "keydown.esc")) do %> ... <% end %>
|
|
16
|
+
<%= link_to "New", new_song_path,
|
|
17
|
+
**on(:new_form, event: ["click", "keydown.n@window"]) %>
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
- A key is heard while focus is inside its element. With `@window` it is
|
|
21
|
+
heard anywhere on the page, for as long as the element is rendered.
|
|
22
|
+
- Keys: `enter`, `tab`, `esc`, `space`, `up`, `down`, `left`, `right`,
|
|
23
|
+
`home`, `end`, `page_up`, `page_down`, a letter, or a digit. Modifiers go
|
|
24
|
+
in front: `keydown.ctrl+s`, `keydown.meta+shift+k`. They are `ctrl`, `meta`
|
|
25
|
+
and `shift`, and they match exactly.
|
|
26
|
+
- A matched key's default is prevented. Keys pressed during IME composition
|
|
27
|
+
and a held key's repeats are ignored.
|
|
28
|
+
- A window key with neither `ctrl` nor `meta` is ignored while the visitor is
|
|
29
|
+
typing in a field, except `esc`.
|
|
30
|
+
- A bare `keydown` raises; `input` is the event for typed text.
|
|
31
|
+
|
|
32
|
+
**`focus`: the cursor in a field a render opens.** A row that turns into a
|
|
33
|
+
form used to leave focus on the page, so the visitor had to click into the
|
|
34
|
+
field before typing or pressing Escape.
|
|
35
|
+
|
|
36
|
+
```erb
|
|
37
|
+
<%= text_field_tag "title", form.title, **focus(select: true) %>
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
- The client focuses the marked element once, on the render that brings it.
|
|
41
|
+
`select: true` also selects its text.
|
|
42
|
+
- Only a render this tab's own action caused counts. Another visitor's save
|
|
43
|
+
never moves the cursor, and neither does a page refresh.
|
|
44
|
+
- A marked element in the page's first HTML is not focused; use `autofocus`
|
|
45
|
+
there.
|
|
46
|
+
- Focus is not taken from a field whose typed text has yet to be sent.
|
|
47
|
+
- `focus` returns a `{ data: }` hash like `on` and `props`:
|
|
48
|
+
`**on(:set_field, event: :input).deep_merge(focus)`.
|
|
49
|
+
|
|
50
|
+
**Focus comes back when a render removes the focused element.** After Save,
|
|
51
|
+
Cancel or Escape the form is gone, and focus used to fall to `<body>`: a
|
|
52
|
+
keyboard user lost their place, and the island stopped hearing keys. The
|
|
53
|
+
client now gives focus back to a control. Nothing more to write in the view.
|
|
54
|
+
|
|
55
|
+
- A form opened with `focus` returns to the control that opened it: the
|
|
56
|
+
row's Edit link, the New button. The client remembers which control's
|
|
57
|
+
action brought the marked field, and finds it again after a re-render by
|
|
58
|
+
its id, or by its action and `with:` payload.
|
|
59
|
+
- A replaced element with the same id takes the focus, and the caret, its
|
|
60
|
+
predecessor had.
|
|
61
|
+
- Failing both, focus goes to the first control inside the nearest ancestor
|
|
62
|
+
that has an id.
|
|
63
|
+
- Only a control ever takes focus. When none of these is found, as after a
|
|
64
|
+
row is destroyed, focus is left where the browser put it.
|
|
65
|
+
- It applies to any render, on both transports and after a page refresh, and
|
|
66
|
+
never moves focus the visitor has since put somewhere else.
|
|
67
|
+
|
|
68
|
+
### Fixed
|
|
69
|
+
|
|
70
|
+
**Typed text is no longer lost to a render that lands inside the input
|
|
71
|
+
debounce.** A debounced field holds text the server has not heard for a
|
|
72
|
+
quarter second. Any render reaching the field in that wait was drawn without
|
|
73
|
+
it, and the morph wrote the older value over what the visitor had typed. The
|
|
74
|
+
render need not be theirs: another visitor's save of any row was enough.
|
|
75
|
+
|
|
76
|
+
- A field with unsent input now wins over a render. The client records the
|
|
77
|
+
text and the caret as it is typed and puts them back after the render,
|
|
78
|
+
until the debounced send has fired. After that the server's value wins
|
|
79
|
+
again, so Reset and a failed save still overwrite the field.
|
|
80
|
+
- Any action that is not debounced first sends the island's pending
|
|
81
|
+
debounced input, so gestures reach the server in the order they were made:
|
|
82
|
+
typing into a field and pressing Enter inside the wait no longer saves the
|
|
83
|
+
form without the last characters.
|
|
84
|
+
- This covers fields wired with a debounce (`event: :input`). Checkboxes,
|
|
85
|
+
radios, file inputs and multi-selects are not recorded.
|
|
86
|
+
|
|
87
|
+
## 0.16.0 — 2026-10-07
|
|
88
|
+
|
|
89
|
+
### Added
|
|
90
|
+
|
|
91
|
+
**`props`: DOM properties from the view.** Some state of a control has no
|
|
92
|
+
HTML attribute, so no re-rendered fragment could carry it: a checkbox's
|
|
93
|
+
`indeterminate`, a container's scroll position. A page that needed one
|
|
94
|
+
brought JavaScript of its own. The new helper writes the wanted properties
|
|
95
|
+
beside the element's other attributes, and the client assigns them when the
|
|
96
|
+
island connects and after every render, on both transports.
|
|
97
|
+
|
|
98
|
+
```erb
|
|
99
|
+
<%= check_box_tag "all", "1", all_selected,
|
|
100
|
+
**on(:select_all, event: :change)
|
|
101
|
+
.deep_merge(props(indeterminate: some_selected)) %>
|
|
102
|
+
<%= tag.div class: "list", **props(scroll_top: 0, key: page) do %>
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
- `indeterminate:` is assigned after every render, so the server's value wins
|
|
106
|
+
over a click (the browser clears the property when the box is clicked).
|
|
107
|
+
- `scroll_top:` and `scroll_left:` are assigned to a new element, and again
|
|
108
|
+
only when the helper's output changes, so the visitor's own scrolling
|
|
109
|
+
survives unrelated renders. `key:` takes any JSON value and exists to change
|
|
110
|
+
the output: `scroll_top: 0, key: page` scrolls back to the top on each page
|
|
111
|
+
change, though the `0` never changes.
|
|
112
|
+
- The list is an allowlist kept on both sides: an unknown name raises in Ruby
|
|
113
|
+
and is ignored by the client, so a fragment cannot reach `innerHTML` or an
|
|
114
|
+
event handler.
|
|
115
|
+
- `props` returns a `{ data: }` hash like `on`. Two splats of the same key do
|
|
116
|
+
not merge, so combine them with `deep_merge` (or Phlex's `mix`).
|
|
117
|
+
- The element must be inside an island. This is a client change: the scan that
|
|
118
|
+
re-observes `visible` sentinels after a render now assigns properties in the
|
|
119
|
+
same walk.
|
|
120
|
+
|
|
121
|
+
Not covered, by design: properties that already have an attribute (`value`,
|
|
122
|
+
`checked`, `disabled`, `open` on `<details>`) ride the fragment as before.
|
|
123
|
+
Modal dialogs, focus and media playback are method calls, not properties, and
|
|
124
|
+
are left for a later release.
|
|
125
|
+
|
|
126
|
+
### Fixed
|
|
127
|
+
|
|
128
|
+
- A `visible` sentinel inside a page that a refresh stream
|
|
129
|
+
(`broadcast_refresh_effect`) morphed was not re-observed: the page morph
|
|
130
|
+
runs after the stream's own render returns. The island now also rescans on
|
|
131
|
+
`turbo:render`.
|
|
132
|
+
|
|
7
133
|
## 0.15.0 — 2026-09-09
|
|
8
134
|
|
|
9
135
|
### Added
|
|
@@ -44,6 +44,10 @@ const idsOf = (root) => new Set(marked(root).map((element) => element.id))
|
|
|
44
44
|
// `own`: the island's trip records are the honest gesture signal — the
|
|
45
45
|
// control of an in-flight trip, or the last control the island tracked
|
|
46
46
|
// (kept past its trip's settle; a render can land after the ack's grace).
|
|
47
|
+
// No island is no evidence, not a plain mark: an `own` element outside any
|
|
48
|
+
// generic island (a ChannelController subclass over Turbo streams) never
|
|
49
|
+
// animates its leave, since the policy exists to keep rows that paged or
|
|
50
|
+
// filtered away from sliding. A plain mark is the way to always animate.
|
|
47
51
|
const ownsGesture = (island, element) => {
|
|
48
52
|
if (!island) return false
|
|
49
53
|
for (const record of island.busy.values()) {
|
|
@@ -45,7 +45,10 @@
|
|
|
45
45
|
// data-hibiki-params-value='{"record_id":7}' extra subscribe
|
|
46
46
|
// params, merged UNDER channel/cid so they can't override them
|
|
47
47
|
// controls data-hibiki-on="<event>-><action> ..." whitespace-separated;
|
|
48
|
-
// e.g. "click->load_more visible->load_more"
|
|
48
|
+
// e.g. "click->load_more visible->load_more". A key is
|
|
49
|
+
// "keydown.<key>" (heard inside the control) or
|
|
50
|
+
// "keydown.<key>@window" (heard anywhere), with ctrl+ meta+
|
|
51
|
+
// shift+ in front in that order; only the names in KEYS below
|
|
49
52
|
// data-hibiki-with='{"index":3}' optional JSON payload
|
|
50
53
|
// data-hibiki-debounce="250" ms to let the gesture settle
|
|
51
54
|
// data-hibiki-confirm="Are you sure?" window.confirm gate
|
|
@@ -57,6 +60,12 @@
|
|
|
57
60
|
// browser does what the markup says
|
|
58
61
|
// value sites data-hibiki-value="<name>" reactive-value placeholder;
|
|
59
62
|
// the server's transmit_value message updates every match
|
|
63
|
+
// prop sites data-hibiki-props='{"indeterminate":true}' DOM properties
|
|
64
|
+
// that have no attribute, assigned on connect and after each
|
|
65
|
+
// render; only the names in PROPS below, an optional "key"
|
|
66
|
+
// beside them existing to change the text
|
|
67
|
+
// focus sites data-hibiki-focus="true|select" takes focus, and has its
|
|
68
|
+
// text selected, when a render this tab caused inserts it
|
|
60
69
|
//
|
|
61
70
|
// The client writes the protocol's other half at runtime — read-only to
|
|
62
71
|
// app code, addressed to app CSS:
|
|
@@ -85,6 +94,30 @@
|
|
|
85
94
|
// Everything that is not "which event" is a sibling attribute, so the
|
|
86
95
|
// token grammar never has to grow.
|
|
87
96
|
//
|
|
97
|
+
// A debounced field holds text the server has not heard, and two rules keep
|
|
98
|
+
// it. A render landing inside the wait is drawn without the text, so the
|
|
99
|
+
// client records it at input time and puts it back after the render: a
|
|
100
|
+
// field with unsent input wins, until its send fires. And any undebounced
|
|
101
|
+
// action first flushes the island's pending debounces, so gestures reach
|
|
102
|
+
// the server in the order they were made.
|
|
103
|
+
//
|
|
104
|
+
// A focus site takes focus once, on the render that brings it: marked now
|
|
105
|
+
// and not at the scan before, by element or by id. Only a render this tab
|
|
106
|
+
// caused counts, one that arrived while an action was in flight or just
|
|
107
|
+
// after its ack, so a render nobody here asked for never moves the cursor.
|
|
108
|
+
// A mark in the page's first HTML is `autofocus`'s business, and a refresh
|
|
109
|
+
// morph focuses nothing.
|
|
110
|
+
//
|
|
111
|
+
// Focus is also given back. When a render removes the element that had
|
|
112
|
+
// focus, it would fall to <body>: a keyboard user loses their place, and
|
|
113
|
+
// the island stops hearing keys. So the client looks, in order, for the
|
|
114
|
+
// element now carrying the same id (a replaced field); for the control
|
|
115
|
+
// whose gesture opened the focus site that render closed (the Edit button
|
|
116
|
+
// of a form now gone), known again after a re-render by its id or by its
|
|
117
|
+
// action and payload; and for the first control of the focused element's
|
|
118
|
+
// nearest id-bearing ancestor. Only controls: when none of these is there,
|
|
119
|
+
// focus is left where the browser put it. Any render, whoever caused it.
|
|
120
|
+
//
|
|
88
121
|
// App JS reaching the graph — the ONE public seam. A gesture that needs
|
|
89
122
|
// script (drag-and-drop, a third-party widget) fires its action through
|
|
90
123
|
// the island's OWN subscription: `perform(action, payload)` on the island
|
|
@@ -142,7 +175,9 @@ const islands = new WeakMap()
|
|
|
142
175
|
// The generic island CONTAINING element, or undefined. The walk performOn
|
|
143
176
|
// uses, exported for the motion module's `own` policy — which reads the
|
|
144
177
|
// island's trip records, so a ChannelController subclass (not in the map)
|
|
145
|
-
//
|
|
178
|
+
// yields no records: its `own` marks never animate a leave a Turbo stream
|
|
179
|
+
// removes. (The transmit swap's event carries the subclass itself, so
|
|
180
|
+
// `own` works there once the subclass tracks its controls.)
|
|
146
181
|
export function islandFor(element) {
|
|
147
182
|
for (let node = element; node; node = node.parentElement) {
|
|
148
183
|
const island = islands.get(node)
|
|
@@ -226,9 +261,15 @@ export class ChannelController extends Controller {
|
|
|
226
261
|
// busyCeiling ms before a trip is declared stalled rather than silently
|
|
227
262
|
// cleared. On a bad link "we lost it" beats "nothing
|
|
228
263
|
// happened".
|
|
264
|
+
// focusGrace ms after a trip settles in which a render still counts as
|
|
265
|
+
// its answer (see own()). One action can broadcast twice: the
|
|
266
|
+
// first render settles the trip with the ack and the second
|
|
267
|
+
// trails both. Longer than busyGrace because a missed focus
|
|
268
|
+
// costs more than a spinner's flicker.
|
|
229
269
|
static busyDelay = 150
|
|
230
270
|
static busyGrace = 60
|
|
231
271
|
static busyCeiling = 10000
|
|
272
|
+
static focusGrace = 500
|
|
232
273
|
|
|
233
274
|
async connect() {
|
|
234
275
|
this.prepareTransport()
|
|
@@ -248,6 +289,7 @@ export class ChannelController extends Controller {
|
|
|
248
289
|
this.seq = 0
|
|
249
290
|
this.renders = 0
|
|
250
291
|
this.busy = new Map()
|
|
292
|
+
this.settledAt = -Infinity
|
|
251
293
|
this.lastControl = null
|
|
252
294
|
this.queued = []
|
|
253
295
|
this.setState("connecting")
|
|
@@ -401,7 +443,9 @@ export class ChannelController extends Controller {
|
|
|
401
443
|
// The most recent gesture, kept past its trip's settle for the motion
|
|
402
444
|
// module's `own` policy (a render can land after the ack's grace). Set
|
|
403
445
|
// before the record check: a send that died on the socket was still a
|
|
404
|
-
// gesture. A ChannelController subclass wanting `own` calls this itself
|
|
446
|
+
// gesture. A ChannelController subclass wanting `own` calls this itself —
|
|
447
|
+
// which serves the transmit swap only: a Turbo stream reaches the
|
|
448
|
+
// records through islandFor, and that walk lists generic islands alone.
|
|
405
449
|
this.lastControl = control
|
|
406
450
|
const record = this.busy.get(seq)
|
|
407
451
|
if (!record) return
|
|
@@ -439,6 +483,7 @@ export class ChannelController extends Controller {
|
|
|
439
483
|
clearTimeout(record.ceiling)
|
|
440
484
|
clearTimeout(record.grace)
|
|
441
485
|
this.busy.delete(seq)
|
|
486
|
+
this.settledAt = performance.now()
|
|
442
487
|
record.control?.removeAttribute("data-hibiki-busy")
|
|
443
488
|
if (this.busy.size === 0) this.hideBusy()
|
|
444
489
|
}
|
|
@@ -456,6 +501,13 @@ export class ChannelController extends Controller {
|
|
|
456
501
|
this.element.removeAttribute("aria-busy")
|
|
457
502
|
}
|
|
458
503
|
|
|
504
|
+
// Whether a render arriving now answers this tab's own action: one is in
|
|
505
|
+
// flight, or settled within the grace. Asked when the render arrives, not
|
|
506
|
+
// when it is applied, since the motion module may hold it for a second.
|
|
507
|
+
own() {
|
|
508
|
+
return this.busy.size > 0 || performance.now() - this.settledAt <= this.constructor.focusGrace
|
|
509
|
+
}
|
|
510
|
+
|
|
459
511
|
// Say so rather than clearing silently: on a bad link the honest report
|
|
460
512
|
// is "we lost it", and a cleared indicator claims the opposite.
|
|
461
513
|
stall(seq) {
|
|
@@ -578,6 +630,122 @@ export class ChannelController extends Controller {
|
|
|
578
630
|
}
|
|
579
631
|
}
|
|
580
632
|
|
|
633
|
+
// The DOM properties a fragment may set (Helpers#props), and when. A
|
|
634
|
+
// property has no attribute, so no swap or morph carries it: the client
|
|
635
|
+
// assigns it after the fact. The table is the allowlist — a fragment must
|
|
636
|
+
// never reach innerHTML or onclick — and a value of the wrong type is
|
|
637
|
+
// skipped. `always` is re-assigned after every render (the browser clears
|
|
638
|
+
// indeterminate on click; the server's value comes back). `changed` is
|
|
639
|
+
// assigned only to a new element or when the attribute's text differs from
|
|
640
|
+
// the text last applied, so the visitor's own scrolling survives unrelated
|
|
641
|
+
// renders; the view's `key:` is how it says "again".
|
|
642
|
+
const PROPS = {
|
|
643
|
+
indeterminate: ["boolean", "always"],
|
|
644
|
+
scrollTop: ["number", "changed"],
|
|
645
|
+
scrollLeft: ["number", "changed"]
|
|
646
|
+
}
|
|
647
|
+
const appliedProps = new WeakMap()
|
|
648
|
+
|
|
649
|
+
// Fields whose state is not their `value`, so there is no typed text to keep.
|
|
650
|
+
const typed = (field) =>
|
|
651
|
+
typeof field.value === "string" &&
|
|
652
|
+
!field.multiple &&
|
|
653
|
+
!["checkbox", "radio", "file"].includes(field.type)
|
|
654
|
+
|
|
655
|
+
// The keys a `keydown.<key>` token may name, by the `event.key` each
|
|
656
|
+
// reports; a single letter or digit names itself. The server keeps the same
|
|
657
|
+
// list (Keys in the gem) and emits nothing outside it.
|
|
658
|
+
const KEYS = {
|
|
659
|
+
enter: "Enter",
|
|
660
|
+
tab: "Tab",
|
|
661
|
+
esc: "Escape",
|
|
662
|
+
space: " ",
|
|
663
|
+
up: "ArrowUp",
|
|
664
|
+
down: "ArrowDown",
|
|
665
|
+
left: "ArrowLeft",
|
|
666
|
+
right: "ArrowRight",
|
|
667
|
+
home: "Home",
|
|
668
|
+
end: "End",
|
|
669
|
+
page_up: "PageUp",
|
|
670
|
+
page_down: "PageDown"
|
|
671
|
+
}
|
|
672
|
+
const KEYED = '[data-hibiki-on*="keydown."]'
|
|
673
|
+
const HOTKEYED = '[data-hibiki-on*="@window"]'
|
|
674
|
+
const ISLAND = '[data-controller~="hibiki"]'
|
|
675
|
+
|
|
676
|
+
// A key press that is a gesture: not part of an IME composition (Safari
|
|
677
|
+
// reports the Enter that ends one only as keyCode 229), not a held key's
|
|
678
|
+
// repeat, and not Chrome's keyless autofill event.
|
|
679
|
+
const heard = (event) =>
|
|
680
|
+
typeof event.key === "string" &&
|
|
681
|
+
!event.isComposing &&
|
|
682
|
+
event.keyCode !== 229 &&
|
|
683
|
+
!event.repeat
|
|
684
|
+
|
|
685
|
+
// Where a plain key is text, or moves within it.
|
|
686
|
+
const NOT_TEXT = new Set([
|
|
687
|
+
"checkbox", "radio", "file", "button", "submit", "reset", "image", "range", "color"
|
|
688
|
+
])
|
|
689
|
+
const entering = (target) =>
|
|
690
|
+
!!target.closest?.('textarea, select, [contenteditable]:not([contenteditable="false"])') ||
|
|
691
|
+
(target.localName === "input" && !NOT_TEXT.has(target.type))
|
|
692
|
+
|
|
693
|
+
// The action of the control's first key token of this scope ("" or
|
|
694
|
+
// "window") that the event matches. Modifiers match exactly, and Alt
|
|
695
|
+
// matches nothing: on macOS it changes the character the key reports.
|
|
696
|
+
// `typing` is a window key pressed in a text field, where only Escape and
|
|
697
|
+
// ctrl or meta combinations are not the field's own.
|
|
698
|
+
function keyAction(control, event, scope, typing = false) {
|
|
699
|
+
const pressed = event.key.length === 1 ? event.key.toLowerCase() : event.key
|
|
700
|
+
for (const token of (control.dataset.hibikiOn ?? "").split(/\s+/)) {
|
|
701
|
+
if (!token.startsWith("keydown.")) continue
|
|
702
|
+
const [bound, action] = token.slice(8).split("->")
|
|
703
|
+
const [combo, at = ""] = bound.split("@")
|
|
704
|
+
if (at !== scope) continue
|
|
705
|
+
const modifiers = combo.split("+")
|
|
706
|
+
const name = modifiers.pop()
|
|
707
|
+
if ((KEYS[name] ?? (name.length === 1 ? name : null)) !== pressed) continue
|
|
708
|
+
const ctrl = modifiers.includes("ctrl")
|
|
709
|
+
const meta = modifiers.includes("meta")
|
|
710
|
+
if (typing && !ctrl && !meta && name !== "esc") continue
|
|
711
|
+
if (
|
|
712
|
+
event.ctrlKey === ctrl &&
|
|
713
|
+
event.metaKey === meta &&
|
|
714
|
+
event.shiftKey === modifiers.includes("shift") &&
|
|
715
|
+
!event.altKey
|
|
716
|
+
) {
|
|
717
|
+
return action
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// What focus return may land on inside a surviving ancestor.
|
|
723
|
+
const FOCUSABLE =
|
|
724
|
+
'a[href], button:not(:disabled), input:not(:disabled):not([type="hidden"]), ' +
|
|
725
|
+
'select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])'
|
|
726
|
+
|
|
727
|
+
// Key events a window binding answered, so a second island declaring the
|
|
728
|
+
// same key still fires where a key taken by anything else is left alone.
|
|
729
|
+
const hotkeyed = new WeakSet()
|
|
730
|
+
|
|
731
|
+
function applyProps(element) {
|
|
732
|
+
const text = element.getAttribute("data-hibiki-props")
|
|
733
|
+
let wanted
|
|
734
|
+
try {
|
|
735
|
+
wanted = JSON.parse(text)
|
|
736
|
+
} catch {
|
|
737
|
+
return
|
|
738
|
+
}
|
|
739
|
+
if (!wanted || typeof wanted !== "object") return
|
|
740
|
+
const fresh = appliedProps.get(element) !== text
|
|
741
|
+
for (const [name, [type, policy]] of Object.entries(PROPS)) {
|
|
742
|
+
const value = wanted[name]
|
|
743
|
+
if (typeof value !== type || (type === "number" && !Number.isFinite(value))) continue
|
|
744
|
+
if (policy === "always" || fresh) element[name] = value
|
|
745
|
+
}
|
|
746
|
+
appliedProps.set(element, text)
|
|
747
|
+
}
|
|
748
|
+
|
|
581
749
|
// The generic controller: adds the data-hibiki-* wire protocol on top of
|
|
582
750
|
// the base's plumbing.
|
|
583
751
|
export default class HibikiController extends ChannelController {
|
|
@@ -607,6 +775,12 @@ export default class HibikiController extends ChannelController {
|
|
|
607
775
|
// rather than from the middle of it.
|
|
608
776
|
this.prepareTransport()
|
|
609
777
|
|
|
778
|
+
// First paint, so before the subscription: a property is right while
|
|
779
|
+
// the island is still connecting, and stays right if it never does.
|
|
780
|
+
for (const site of this.element.querySelectorAll("[data-hibiki-props]")) {
|
|
781
|
+
applyProps(site)
|
|
782
|
+
}
|
|
783
|
+
|
|
610
784
|
// Root-scoped delegation (bound to the island, not document): controls
|
|
611
785
|
// inside server-replaced fragments keep working with no rebinding.
|
|
612
786
|
// Set up synchronously so disconnect can always tear them down.
|
|
@@ -615,12 +789,29 @@ export default class HibikiController extends ChannelController {
|
|
|
615
789
|
this.element.addEventListener(type, handler)
|
|
616
790
|
return [type, handler]
|
|
617
791
|
})
|
|
792
|
+
// Keys take their own path: a key names its control by a token, not by
|
|
793
|
+
// being the nearest one.
|
|
794
|
+
const keydown = (event) => this.key(event)
|
|
795
|
+
this.element.addEventListener("keydown", keydown)
|
|
796
|
+
this.listeners.push(["keydown", keydown])
|
|
797
|
+
// On the window only while the island has a window key; see scan().
|
|
798
|
+
this.hotkeys = (event) => this.hotkey(event)
|
|
618
799
|
|
|
619
800
|
// Debounce bookkeeping: a WeakMap keyed by control (so detached
|
|
620
|
-
// elements don't pin memory) plus
|
|
621
|
-
// what disconnect can actually iterate.
|
|
801
|
+
// elements don't pin memory) plus the live timeouts with what each
|
|
802
|
+
// will run, which is what disconnect and flush can actually iterate.
|
|
803
|
+
// `unsent` is the text those timeouts have yet to send, by field.
|
|
622
804
|
this.timers = new WeakMap()
|
|
623
|
-
this.pending = new
|
|
805
|
+
this.pending = new Map()
|
|
806
|
+
this.unsent = new Map()
|
|
807
|
+
// The focus sites seen at the last scan, each with the control whose
|
|
808
|
+
// gesture opened it, when one did; a site not among them is new.
|
|
809
|
+
// Seeded now, so a render landing in the connect window finds the
|
|
810
|
+
// page's own marks already seen.
|
|
811
|
+
this.marks = new Map()
|
|
812
|
+
for (const site of this.element.querySelectorAll("[data-hibiki-focus]")) {
|
|
813
|
+
if (site.closest(ISLAND) === this.element) this.marks.set(site, undefined)
|
|
814
|
+
}
|
|
624
815
|
|
|
625
816
|
// `visible` is not a DOM event, so it needs its own observer beside the
|
|
626
817
|
// delegated listeners. Always on, never a pluggable module: an
|
|
@@ -639,11 +830,12 @@ export default class HibikiController extends ChannelController {
|
|
|
639
830
|
}
|
|
640
831
|
})
|
|
641
832
|
|
|
642
|
-
// Re-scan at the
|
|
833
|
+
// Re-scan at the points a fragment can be swapped under us, rather
|
|
643
834
|
// than blanket-observing the document: a MutationObserver over the page
|
|
644
835
|
// is a real per-mutation cost paid by every app on it.
|
|
645
836
|
this.streamRender = (event) => {
|
|
646
837
|
const render = event.detail.render
|
|
838
|
+
const arrival = this.arrival()
|
|
647
839
|
event.detail.render = async (streamElement) => {
|
|
648
840
|
await render(streamElement)
|
|
649
841
|
// The Turbo transport's paint. Counting it here is what lets an ack
|
|
@@ -652,14 +844,28 @@ export default class HibikiController extends ChannelController {
|
|
|
652
844
|
// — which at worst settles an already-acked trip a few ms early,
|
|
653
845
|
// never one that has not been acked at all.
|
|
654
846
|
this.renders++
|
|
655
|
-
this.
|
|
847
|
+
this.scan(arrival)
|
|
656
848
|
}
|
|
657
849
|
}
|
|
658
850
|
document.addEventListener("turbo:before-stream-render", this.streamRender)
|
|
851
|
+
// A refresh stream morphs the page after its own render has returned,
|
|
852
|
+
// so the wrapper above scans too early for it; turbo:render follows
|
|
853
|
+
// the morph.
|
|
854
|
+
// It is nobody's own render, so it brings no focus, but it can
|
|
855
|
+
// take the focused element away like any other.
|
|
856
|
+
this.pageBefore = () => {
|
|
857
|
+
this.paging = { trail: this.trail() }
|
|
858
|
+
}
|
|
859
|
+
this.pageRender = () => {
|
|
860
|
+
this.scan(this.paging)
|
|
861
|
+
this.paging = undefined
|
|
862
|
+
}
|
|
863
|
+
document.addEventListener("turbo:before-render", this.pageBefore)
|
|
864
|
+
document.addEventListener("turbo:render", this.pageRender)
|
|
659
865
|
|
|
660
866
|
await this.openSubscription()
|
|
661
867
|
if (this.aborted) return
|
|
662
|
-
this.
|
|
868
|
+
this.scan()
|
|
663
869
|
}
|
|
664
870
|
|
|
665
871
|
disconnect() {
|
|
@@ -668,8 +874,13 @@ export default class HibikiController extends ChannelController {
|
|
|
668
874
|
this.element.removeEventListener(type, handler)
|
|
669
875
|
}
|
|
670
876
|
document.removeEventListener("turbo:before-stream-render", this.streamRender)
|
|
671
|
-
|
|
877
|
+
document.removeEventListener("turbo:before-render", this.pageBefore)
|
|
878
|
+
document.removeEventListener("turbo:render", this.pageRender)
|
|
879
|
+
window.removeEventListener("keydown", this.hotkeys)
|
|
880
|
+
for (const id of this.pending.keys()) clearTimeout(id)
|
|
672
881
|
this.pending.clear()
|
|
882
|
+
this.unsent.clear()
|
|
883
|
+
this.marks.clear()
|
|
673
884
|
this.observer.disconnect()
|
|
674
885
|
super.disconnect()
|
|
675
886
|
}
|
|
@@ -677,21 +888,179 @@ export default class HibikiController extends ChannelController {
|
|
|
677
888
|
// The other swap point: hibiki's own transmit transport. A held swap
|
|
678
889
|
// hands back a promise; the replacement sentinel exists only after it.
|
|
679
890
|
received(data) {
|
|
891
|
+
const arrival = this.arrival()
|
|
680
892
|
const swapped = super.received(data)
|
|
681
893
|
if (!data.html) return
|
|
682
|
-
if (swapped?.then) swapped.then(() => this.
|
|
683
|
-
else this.
|
|
894
|
+
if (swapped?.then) swapped.then(() => this.scan(arrival))
|
|
895
|
+
else this.scan(arrival)
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// What is only knowable as a render arrives: whether it answers this
|
|
899
|
+
// tab's action, the control that action came from (the motion module
|
|
900
|
+
// forgets it once a held render lands), and where focus was.
|
|
901
|
+
arrival() {
|
|
902
|
+
return { own: this.own(), control: this.lastControl, trail: this.trail() }
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// The focused element, when it is this island's, with the ids from it
|
|
906
|
+
// upward, nearest first, and its caret.
|
|
907
|
+
trail() {
|
|
908
|
+
const element = document.activeElement
|
|
909
|
+
if (!element || element === this.element || element.closest?.(ISLAND) !== this.element) return
|
|
910
|
+
const ids = []
|
|
911
|
+
for (let node = element; node !== this.element; node = node.parentElement) {
|
|
912
|
+
if (node.id) ids.push(node.id)
|
|
913
|
+
}
|
|
914
|
+
if (this.element.id) ids.push(this.element.id)
|
|
915
|
+
return { element, ids, self: Boolean(element.id), start: element.selectionStart, end: element.selectionEnd }
|
|
684
916
|
}
|
|
685
917
|
|
|
686
|
-
//
|
|
687
|
-
//
|
|
688
|
-
//
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
918
|
+
// Give focus back after a render removed the element that had it; the
|
|
919
|
+
// header has the rule. `opener` is the control behind a focus site the
|
|
920
|
+
// same render closed. Left alone when focus has gone somewhere since.
|
|
921
|
+
refocus({ element, ids, self, start, end }, opener) {
|
|
922
|
+
if (element.isConnected) return
|
|
923
|
+
const active = document.activeElement
|
|
924
|
+
if ((active && active !== document.body && active.isConnected) || !this.element.isConnected) return
|
|
925
|
+
const found = (id) => {
|
|
926
|
+
const survivor = document.getElementById(id)
|
|
927
|
+
return survivor && this.element.contains(survivor) ? survivor : null
|
|
928
|
+
}
|
|
929
|
+
const index = ids.findIndex(found)
|
|
930
|
+
if (self && index === 0) {
|
|
931
|
+
const twin = found(ids[0])
|
|
932
|
+
twin.focus()
|
|
933
|
+
if (start != null && document.activeElement === twin) twin.setSelectionRange?.(start, end)
|
|
934
|
+
return
|
|
935
|
+
}
|
|
936
|
+
const candidates = [opener && this.twin(opener)]
|
|
937
|
+
if (index === (self ? 1 : 0)) candidates.push(...found(ids[index]).querySelectorAll(FOCUSABLE))
|
|
938
|
+
for (const control of candidates) {
|
|
939
|
+
control?.focus()
|
|
940
|
+
if (control && document.activeElement === control) return
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// The control a gesture came from, or the one a render has put in its
|
|
945
|
+
// place: the same id, else the same action and payload.
|
|
946
|
+
twin({ element, id, on, payload }) {
|
|
947
|
+
if (element.isConnected) return element
|
|
948
|
+
const twin = id
|
|
949
|
+
? document.getElementById(id)
|
|
950
|
+
: [...this.element.querySelectorAll("[data-hibiki-on]")].find(
|
|
951
|
+
(control) => control.dataset.hibikiOn === on && control.dataset.hibikiWith === payload
|
|
952
|
+
)
|
|
953
|
+
return twin?.closest(ISLAND) === this.element ? twin : null
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// What a render leaves for the client to finish: put back unsent text,
|
|
957
|
+
// then in one walk of the island assign every prop site's properties,
|
|
958
|
+
// observe every `visible->` sentinel this island owns, listen on the
|
|
959
|
+
// window while a window key is declared, and settle focus: on the first
|
|
960
|
+
// focus site the render brought when it was this tab's `own`, else back
|
|
961
|
+
// to a control when the render removed the focused element. Focus before
|
|
962
|
+
// the unsent text, whose caret goes only to the focused field. observe()
|
|
963
|
+
// is a no-op for an element already being observed, so re-scanning is
|
|
964
|
+
// cheap and cannot double-fire a sentinel that merely stayed put; adding
|
|
965
|
+
// a listener twice is a no-op too. Props need no ownership check: assigning one twice,
|
|
966
|
+
// from a nested island and its parent, lands the same value. Nor does
|
|
967
|
+
// the window listener, which finds nothing of its own to fire.
|
|
968
|
+
scan({ own = false, control, trail } = {}) {
|
|
969
|
+
const found = this.element.querySelectorAll(
|
|
970
|
+
`[data-hibiki-on*="visible->"], [data-hibiki-props], [data-hibiki-focus], ${HOTKEYED}`
|
|
971
|
+
)
|
|
972
|
+
let hotkeys = this.element.matches(HOTKEYED)
|
|
973
|
+
// A site is the one seen before when it is that element or, after a
|
|
974
|
+
// replace, carries its id; it keeps the opener either way.
|
|
975
|
+
const seen = new Map()
|
|
976
|
+
for (const [was, opener] of this.marks) if (was.id) seen.set(was.id, opener)
|
|
977
|
+
const marks = new Map()
|
|
978
|
+
const ids = new Set()
|
|
979
|
+
let site
|
|
980
|
+
for (const element of found) {
|
|
981
|
+
if (element.hasAttribute("data-hibiki-focus") && element.closest(ISLAND) === this.element) {
|
|
982
|
+
marks.set(element, this.marks.get(element) ?? seen.get(element.id))
|
|
983
|
+
if (element.id) ids.add(element.id)
|
|
984
|
+
if (!this.marks.has(element) && !seen.has(element.id)) site ??= element
|
|
693
985
|
}
|
|
986
|
+
hotkeys ||= element.dataset.hibikiOn?.includes("@window")
|
|
987
|
+
if (element.hasAttribute("data-hibiki-props")) applyProps(element)
|
|
988
|
+
if (
|
|
989
|
+
element.dataset.hibikiOn?.includes("visible->") &&
|
|
990
|
+
element.closest('[data-controller~="hibiki"]') === this.element
|
|
991
|
+
) {
|
|
992
|
+
this.observer.observe(element)
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
if (hotkeys) window.addEventListener("keydown", this.hotkeys)
|
|
996
|
+
else window.removeEventListener("keydown", this.hotkeys)
|
|
997
|
+
// The opener of a site this render closed, for the focus it leaves.
|
|
998
|
+
let opener
|
|
999
|
+
for (const [was, its] of this.marks) {
|
|
1000
|
+
if (its && !marks.has(was) && !ids.has(was.id)) opener = its
|
|
694
1001
|
}
|
|
1002
|
+
this.marks = marks
|
|
1003
|
+
// Not from a field with unsent input: the visitor is typing there.
|
|
1004
|
+
if (own && site && !this.unsent.has(trail?.element)) {
|
|
1005
|
+
if (control) {
|
|
1006
|
+
const { id, dataset } = control
|
|
1007
|
+
marks.set(site, { element: control, id, on: dataset.hibikiOn, payload: dataset.hibikiWith })
|
|
1008
|
+
}
|
|
1009
|
+
const take = () => {
|
|
1010
|
+
site.focus()
|
|
1011
|
+
if (site.dataset.hibikiFocus === "select") site.select?.()
|
|
1012
|
+
}
|
|
1013
|
+
take()
|
|
1014
|
+
// Turbo hands focus back to the id that had it before a stream, one
|
|
1015
|
+
// frame after appending it, which is just after this. Take it again.
|
|
1016
|
+
const id = trail?.element.id
|
|
1017
|
+
if (id) {
|
|
1018
|
+
setTimeout(() => {
|
|
1019
|
+
if (site.isConnected && document.activeElement?.id === id) take()
|
|
1020
|
+
}, 0)
|
|
1021
|
+
}
|
|
1022
|
+
} else if (trail) {
|
|
1023
|
+
this.refocus(trail, opener)
|
|
1024
|
+
}
|
|
1025
|
+
if (this.unsent.size) this.restoreUnsent()
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// A key pressed inside the island: the nearest control up from the
|
|
1029
|
+
// target whose key token matches. Not forward()'s nearest control, since
|
|
1030
|
+
// a field wired for `input` sits inside the form that takes Escape. A
|
|
1031
|
+
// key something else took (a nested island, the page's script) is left.
|
|
1032
|
+
key(event) {
|
|
1033
|
+
if (event.defaultPrevented || !heard(event)) return
|
|
1034
|
+
let control = event.target.closest?.(KEYED)
|
|
1035
|
+
while (control && this.element.contains(control)) {
|
|
1036
|
+
const action = control.closest(ISLAND) === this.element && keyAction(control, event, "")
|
|
1037
|
+
if (action) return this.press(control, event, action)
|
|
1038
|
+
control = control.parentElement?.closest(KEYED)
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// A key pressed anywhere: the island's first enabled control declaring
|
|
1043
|
+
// it `@window`, the root included. One an inside control answered has
|
|
1044
|
+
// its default prevented, and so is not heard here.
|
|
1045
|
+
hotkey(event) {
|
|
1046
|
+
if ((event.defaultPrevented && !hotkeyed.has(event)) || !heard(event)) return
|
|
1047
|
+
const typing = entering(event.target)
|
|
1048
|
+
for (const control of [this.element, ...this.element.querySelectorAll(HOTKEYED)]) {
|
|
1049
|
+
if (control.closest(ISLAND) !== this.element || control.matches(":disabled")) continue
|
|
1050
|
+
const action = keyAction(control, event, "window", typing)
|
|
1051
|
+
if (!action) continue
|
|
1052
|
+
hotkeyed.add(event)
|
|
1053
|
+
return this.press(control, event, action)
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// A matched key is the control's gesture, so its default goes. A fallback
|
|
1058
|
+
// control on an island that is not ready stands aside: a key has no
|
|
1059
|
+
// native destination, but its default may be the form's own submit.
|
|
1060
|
+
press(control, event, action) {
|
|
1061
|
+
if ("hibikiFallback" in control.dataset && this.state !== "ready") return
|
|
1062
|
+
event.preventDefault()
|
|
1063
|
+
this.run(control, event, action)
|
|
695
1064
|
}
|
|
696
1065
|
|
|
697
1066
|
// DOM → server: forward a control's event as a channel action.
|
|
@@ -711,8 +1080,11 @@ export default class HibikiController extends ChannelController {
|
|
|
711
1080
|
.split(/\s+/)
|
|
712
1081
|
.find((t) => t.startsWith(`${event.type}->`))
|
|
713
1082
|
if (!token) return
|
|
714
|
-
|
|
1083
|
+
this.run(control, event, token.slice(event.type.length + 2))
|
|
1084
|
+
}
|
|
715
1085
|
|
|
1086
|
+
// From a control's matched token to the wire.
|
|
1087
|
+
run(control, event, action) {
|
|
716
1088
|
// A fallback control's native behavior IS the degraded path: unless
|
|
717
1089
|
// the island is `ready`, stand aside — no perform, no queueing — and
|
|
718
1090
|
// the browser follows the href or submits the form to its own
|
|
@@ -742,8 +1114,48 @@ export default class HibikiController extends ChannelController {
|
|
|
742
1114
|
// keystroke that started the timer.
|
|
743
1115
|
const wait = Number(control.dataset.hibikiDebounce)
|
|
744
1116
|
const fire = () => this.send(control, event, action)
|
|
745
|
-
if (wait > 0)
|
|
746
|
-
|
|
1117
|
+
if (wait > 0) {
|
|
1118
|
+
const field = event.target
|
|
1119
|
+
if (event.type !== "keydown" && typed(field)) {
|
|
1120
|
+
this.unsent.set(field, {
|
|
1121
|
+
value: field.value,
|
|
1122
|
+
start: field.selectionStart,
|
|
1123
|
+
end: field.selectionEnd
|
|
1124
|
+
})
|
|
1125
|
+
}
|
|
1126
|
+
this.debounce(control, action, wait, () => {
|
|
1127
|
+
fire()
|
|
1128
|
+
// Sent: from here the server's value for the field wins again.
|
|
1129
|
+
if (!this.timers.get(control)?.size) this.unsent.delete(field)
|
|
1130
|
+
})
|
|
1131
|
+
} else {
|
|
1132
|
+
this.flush()
|
|
1133
|
+
fire()
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// A render drew these fields without the text typed since their last
|
|
1138
|
+
// send. A morph kept the element; a replace swapped in another with the
|
|
1139
|
+
// same id (the pending send still reads the detached one, whose value is
|
|
1140
|
+
// intact). The selection is the one the last keystroke left, restored
|
|
1141
|
+
// because assigning a value moves the caret to the end.
|
|
1142
|
+
restoreUnsent() {
|
|
1143
|
+
for (const [field, { value, start, end }] of this.unsent) {
|
|
1144
|
+
const live = field.isConnected ? field : field.id && document.getElementById(field.id)
|
|
1145
|
+
if (!live || !typed(live) || live.value === value) continue
|
|
1146
|
+
live.value = value
|
|
1147
|
+
if (start != null && live === document.activeElement) live.setSelectionRange(start, end)
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// Run every pending debounce now, oldest first.
|
|
1152
|
+
flush() {
|
|
1153
|
+
const runs = [...this.pending]
|
|
1154
|
+
this.pending.clear()
|
|
1155
|
+
for (const [id, run] of runs) {
|
|
1156
|
+
clearTimeout(id)
|
|
1157
|
+
run()
|
|
1158
|
+
}
|
|
747
1159
|
}
|
|
748
1160
|
|
|
749
1161
|
send(control, event, action) {
|
|
@@ -800,13 +1212,14 @@ export default class HibikiController extends ChannelController {
|
|
|
800
1212
|
clearTimeout(previous)
|
|
801
1213
|
this.pending.delete(previous)
|
|
802
1214
|
}
|
|
803
|
-
const
|
|
1215
|
+
const run = () => {
|
|
804
1216
|
byAction.delete(action)
|
|
805
1217
|
this.pending.delete(id)
|
|
806
1218
|
fire()
|
|
807
|
-
}
|
|
1219
|
+
}
|
|
1220
|
+
const id = setTimeout(run, wait)
|
|
808
1221
|
byAction.set(action, id)
|
|
809
|
-
this.pending.
|
|
1222
|
+
this.pending.set(id, run)
|
|
810
1223
|
}
|
|
811
1224
|
}
|
|
812
1225
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hibiki
|
|
4
|
+
module Rails
|
|
5
|
+
# The DOM properties Helpers#props may set, and their wire form. The
|
|
6
|
+
# client keeps the same list (PROPS in hibiki.js) and ignores anything
|
|
7
|
+
# outside it, so a fragment can never reach innerHTML or an event
|
|
8
|
+
# handler. Add a property in both places.
|
|
9
|
+
module DomProps
|
|
10
|
+
# Ruby key => [wire name, type].
|
|
11
|
+
ALLOWED = {
|
|
12
|
+
indeterminate: ["indeterminate", :boolean],
|
|
13
|
+
scroll_top: ["scrollTop", :number],
|
|
14
|
+
scroll_left: ["scrollLeft", :number]
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
# One name/value as its wire pair, or nil when there is nothing to
|
|
18
|
+
# send. A boolean is always sent, false included: a morph keeps the
|
|
19
|
+
# element, so an omitted false would leave the property set.
|
|
20
|
+
def self.pair(name, value)
|
|
21
|
+
wire, type = ALLOWED.fetch(name.to_sym) do
|
|
22
|
+
raise ArgumentError,
|
|
23
|
+
"DOM property #{name.inspect} must be one of #{ALLOWED.keys.join(', ')}"
|
|
24
|
+
end
|
|
25
|
+
return [wire, value ? true : false] if type == :boolean
|
|
26
|
+
return if value.nil?
|
|
27
|
+
return [wire, value] if value.is_a?(Numeric) && value.to_f.finite?
|
|
28
|
+
|
|
29
|
+
raise ArgumentError, "DOM property #{name} must be a finite number, got #{value.inspect}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
data/lib/hibiki/rails/helpers.rb
CHANGED
|
@@ -4,6 +4,9 @@ require "cgi/escape"
|
|
|
4
4
|
require "json"
|
|
5
5
|
require "securerandom"
|
|
6
6
|
|
|
7
|
+
require_relative "dom_props"
|
|
8
|
+
require_relative "keys"
|
|
9
|
+
|
|
7
10
|
module Hibiki
|
|
8
11
|
module Rails
|
|
9
12
|
# Opt-in helpers that stamp the packaged client's attribute protocol
|
|
@@ -44,9 +47,8 @@ module Hibiki
|
|
|
44
47
|
|
|
45
48
|
# Event and action names share the hibiki_on attribute, which is a
|
|
46
49
|
# whitespace-separated token list — a name carrying a space or an
|
|
47
|
-
# arrow would silently change what the client dispatches.
|
|
48
|
-
#
|
|
49
|
-
# grammar change.
|
|
50
|
+
# arrow would silently change what the client dispatches. Key events
|
|
51
|
+
# (keydown.enter) have their own grammar; see Keys.
|
|
50
52
|
EVENT_NAME = /\A[a-z][a-z0-9_.-]*\z/i
|
|
51
53
|
|
|
52
54
|
# Per-keystroke round trips are the failure mode #on exists to avoid,
|
|
@@ -101,6 +103,13 @@ module Hibiki
|
|
|
101
103
|
name
|
|
102
104
|
end
|
|
103
105
|
|
|
106
|
+
# The left side of a token. Keys validates a key event, and anything
|
|
107
|
+
# else that asks for a scope.
|
|
108
|
+
def self.event(name)
|
|
109
|
+
name = name.to_s
|
|
110
|
+
name.match?(Keys::EVENT) || name.include?("@") ? Keys.token(name) : event_name(name)
|
|
111
|
+
end
|
|
112
|
+
|
|
104
113
|
# The island root: one channel subscription per island, identified by
|
|
105
114
|
# a per-page-load cid (each tab is its own graph). `channel` is the
|
|
106
115
|
# channel class or its name as a string.
|
|
@@ -123,8 +132,7 @@ module Hibiki
|
|
|
123
132
|
# override `channel` or `cid` through this hash.
|
|
124
133
|
def hibiki_island(channel, cid:, params: nil)
|
|
125
134
|
channel_name = channel.is_a?(Class) ? channel.name : channel.to_s
|
|
126
|
-
data = { controller: "hibiki", hibiki_channel_value: channel_name,
|
|
127
|
-
hibiki_cid_value: cid }
|
|
135
|
+
data = { controller: "hibiki", hibiki_channel_value: channel_name, hibiki_cid_value: cid }
|
|
128
136
|
data[:hibiki_params_value] = JSON.generate(params) unless params.nil?
|
|
129
137
|
{ data: }
|
|
130
138
|
end
|
|
@@ -167,6 +175,13 @@ module Hibiki
|
|
|
167
175
|
#
|
|
168
176
|
# on(:load_more, event: %i[click visible], with: { shown: rows.size })
|
|
169
177
|
#
|
|
178
|
+
# A key is `keydown.<key>`, heard while focus is inside the element,
|
|
179
|
+
# or `keydown.<key>@window`, heard anywhere on the page while the
|
|
180
|
+
# element is rendered. Modifiers go in front; Keys lists both:
|
|
181
|
+
#
|
|
182
|
+
# on(:cancel, event: "keydown.esc")
|
|
183
|
+
# on(:new_row, event: ["click", "keydown.ctrl+n@window"])
|
|
184
|
+
#
|
|
170
185
|
# `with:` is a hash sent as the action's payload. The client adds
|
|
171
186
|
# event-derived data on top: a changed control contributes
|
|
172
187
|
# `{ name => value }` — a checkbox its checked state, a multi-select
|
|
@@ -204,7 +219,7 @@ module Hibiki
|
|
|
204
219
|
def on(action, event: :click, with: nil, debounce: nil, confirm: nil, reset: nil,
|
|
205
220
|
fallback: nil)
|
|
206
221
|
action = Helpers.event_name(action)
|
|
207
|
-
events = Array(event).map { Helpers.
|
|
222
|
+
events = Array(event).map { Helpers.event(it) }
|
|
208
223
|
debounce = DEFAULT_INPUT_DEBOUNCE if debounce.nil? && events.include?("input")
|
|
209
224
|
tokens = events.map { "#{it}->#{action}" }.join(" ")
|
|
210
225
|
{ data: { hibiki_on: tokens }
|
|
@@ -230,6 +245,46 @@ module Hibiki
|
|
|
230
245
|
# Phlex form of #reactive: `span(**reactive_attrs(:doubled)) { "0" }`.
|
|
231
246
|
def reactive_attrs(name) = { data: { hibiki_value: Helpers.value_name(name) } }
|
|
232
247
|
|
|
248
|
+
# DOM properties for this element — state that has no HTML attribute,
|
|
249
|
+
# so no fragment can carry it. The client assigns them when the island
|
|
250
|
+
# connects and after every render.
|
|
251
|
+
#
|
|
252
|
+
# tag.input type: "checkbox", **props(indeterminate: some_selected)
|
|
253
|
+
# tag.div class: "list", **props(scroll_top: 0, key: query)
|
|
254
|
+
#
|
|
255
|
+
# indeterminate: true/false. Assigned after every render, so the
|
|
256
|
+
# server's value wins over a click.
|
|
257
|
+
# scroll_top:, scroll_left: pixels, nil to leave alone. Assigned to
|
|
258
|
+
# a new element, and again only when this helper's
|
|
259
|
+
# output changes — the visitor's own scrolling is
|
|
260
|
+
# kept across unrelated renders.
|
|
261
|
+
# key: any JSON value, sent only to change the output:
|
|
262
|
+
# `scroll_top: 0, key: query` scrolls back to the top
|
|
263
|
+
# each time the query changes.
|
|
264
|
+
#
|
|
265
|
+
# The element must be inside an island. Returns a `{ data: }` hash
|
|
266
|
+
# like #on; two splats of one key do not merge, so on an element that
|
|
267
|
+
# has both: `**on(:select_all, event: :change).deep_merge(props(...))`.
|
|
268
|
+
def props(key: nil, **properties)
|
|
269
|
+
wanted = properties.filter_map { |name, value| DomProps.pair(name, value) }.to_h
|
|
270
|
+
return { data: {} } if wanted.empty?
|
|
271
|
+
|
|
272
|
+
wanted["key"] = key unless key.nil?
|
|
273
|
+
{ data: { hibiki_props: JSON.generate(wanted) } }
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Put the cursor in this element when a render opens it:
|
|
277
|
+
#
|
|
278
|
+
# tag.input name: "title", value: form.title, **focus(select: true)
|
|
279
|
+
#
|
|
280
|
+
# The client focuses it once, on the render that brings it, and only
|
|
281
|
+
# when that render answers this tab's own action; another visitor's
|
|
282
|
+
# save never moves the cursor. `select: true` also selects its text.
|
|
283
|
+
# An element in the page's first HTML is not focused: that is the
|
|
284
|
+
# `autofocus` attribute's job. Returns a `{ data: }` hash, merged like
|
|
285
|
+
# #props: `**on(:set_field, event: :input).deep_merge(focus)`.
|
|
286
|
+
def focus(select: false) = { data: { hibiki_focus: select ? "select" : "true" } }
|
|
287
|
+
|
|
233
288
|
private
|
|
234
289
|
|
|
235
290
|
# #island's preconditions: a block, an ActionView receiver, a channel
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hibiki
|
|
4
|
+
module Rails
|
|
5
|
+
# The key events Helpers#on may name, and their wire form:
|
|
6
|
+
# `keydown.[<modifier>+]*<key>[@window]`. The client keeps the same key
|
|
7
|
+
# list (KEYS in hibiki.js) and matches nothing outside it. Add a key in
|
|
8
|
+
# both places.
|
|
9
|
+
module Keys
|
|
10
|
+
# Named keys, Stimulus's spellings. Single letters and digits are
|
|
11
|
+
# keys too.
|
|
12
|
+
NAMES = %w[enter tab esc space up down left right home end page_up page_down].freeze
|
|
13
|
+
CHARACTER = /\A[a-z0-9]\z/
|
|
14
|
+
|
|
15
|
+
# In wire order. No alt: on macOS Option changes the character the
|
|
16
|
+
# key reports, so `alt+s` would never match there.
|
|
17
|
+
MODIFIERS = %w[ctrl meta shift].freeze
|
|
18
|
+
|
|
19
|
+
SCOPES = %w[window].freeze
|
|
20
|
+
|
|
21
|
+
# What Helpers#on hands over: `keydown` alone or followed by a
|
|
22
|
+
# qualifier, not a longer name that starts with it.
|
|
23
|
+
EVENT = /\Akeydown(?![a-z0-9_-])/i
|
|
24
|
+
|
|
25
|
+
# One `keydown...` event as its canonical token: lowercase, modifiers
|
|
26
|
+
# in MODIFIERS order.
|
|
27
|
+
def self.token(event)
|
|
28
|
+
event = event.to_s.downcase
|
|
29
|
+
unless EVENT.match?(event)
|
|
30
|
+
raise ArgumentError, "event #{event.inspect}: only a keydown.<key> event takes a scope"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
combo, scope = event.split("@", 2)
|
|
34
|
+
*modifiers, key = combo.delete_prefix("keydown").delete_prefix(".").split("+")
|
|
35
|
+
key!(event, key)
|
|
36
|
+
modifiers!(event, key, modifiers)
|
|
37
|
+
scope!(event, scope)
|
|
38
|
+
["keydown.#{[*(MODIFIERS & modifiers), key].join('+')}", scope].compact.join("@")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.key!(event, key)
|
|
42
|
+
if key.nil?
|
|
43
|
+
raise ArgumentError,
|
|
44
|
+
"#{event.inspect} names no key; write keydown.<key>, or use :input for typed text"
|
|
45
|
+
end
|
|
46
|
+
return if NAMES.include?(key) || CHARACTER.match?(key)
|
|
47
|
+
|
|
48
|
+
raise ArgumentError,
|
|
49
|
+
"key #{key.inspect} in #{event.inspect} must be a letter, a digit, or one of #{NAMES.join(', ')}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.modifiers!(event, key, modifiers)
|
|
53
|
+
unknown = modifiers - MODIFIERS
|
|
54
|
+
unless unknown.empty?
|
|
55
|
+
raise ArgumentError,
|
|
56
|
+
"modifier #{unknown.first.inspect} in #{event.inspect} must be one of #{MODIFIERS.join(', ')}"
|
|
57
|
+
end
|
|
58
|
+
return unless modifiers.include?("shift") && key.match?(/\A\d\z/)
|
|
59
|
+
|
|
60
|
+
# Shift turns a digit into punctuation, so the key would never match.
|
|
61
|
+
raise ArgumentError, "#{event.inspect}: shift cannot be combined with a digit"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.scope!(event, scope)
|
|
65
|
+
return if scope.nil? || SCOPES.include?(scope)
|
|
66
|
+
|
|
67
|
+
raise ArgumentError, "scope #{scope.inspect} in #{event.inspect} must be #{SCOPES.join(', ')}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private_class_method :key!, :modifiers!, :scope!
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
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.17.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- planetaska
|
|
@@ -183,9 +183,11 @@ files:
|
|
|
183
183
|
- lib/hibiki/rails/broadcasts.rb
|
|
184
184
|
- lib/hibiki/rails/channel.rb
|
|
185
185
|
- lib/hibiki/rails/debounce.rb
|
|
186
|
+
- lib/hibiki/rails/dom_props.rb
|
|
186
187
|
- lib/hibiki/rails/engine.rb
|
|
187
188
|
- lib/hibiki/rails/graph_actor.rb
|
|
188
189
|
- lib/hibiki/rails/helpers.rb
|
|
190
|
+
- lib/hibiki/rails/keys.rb
|
|
189
191
|
- lib/hibiki/rails/nested_actions.rb
|
|
190
192
|
- lib/hibiki/rails/reactive_form.rb
|
|
191
193
|
- lib/hibiki/rails/registry.rb
|