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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ce497d3d19ee46dede8bb9973f3a408e8b724f588ee2ebce3a4c0a0e5aa998f1
4
- data.tar.gz: 1eea58578f6a73f3617a8f717cc1407023d741c1bbd1fb68f534b1fe135a4caa
3
+ metadata.gz: fbcb87c658d3e83cc6c10f216a69e78266878aa8396ed9662e3f06d8c6213027
4
+ data.tar.gz: d6a66d149293d26e43edf4cd31ec20eb5886cc0d3db2b935b5b0c3c333965292
5
5
  SHA512:
6
- metadata.gz: fd67705cbe7db3a1eb1ddcd5dd914e7f8377f01070d564f3dca40a96f205833ef0a442f3a74bc68a8928d6577461a8bc83427c0e0117c711d3fd5733fa478b9e
7
- data.tar.gz: eefeee042142381f1e2f1f2dba0d49d2884db32409a029d08215a9479aefce564bc7e03333c23b525ade2dc37fc3111a89ebbabd52fed6adf450bfe19c9f63bf
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
- // gets plain marks only.
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 a flat set of live timeouts, which is
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 Set()
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 two points a fragment can be swapped under us, rather
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.scanSentinels()
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.scanSentinels()
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
- for (const id of this.pending) clearTimeout(id)
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.scanSentinels())
683
- else this.scanSentinels()
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
- // Observe every `visible->` sentinel this island owns. observe() is a
687
- // no-op for an element already being observed, so re-scanning is cheap
688
- // and cannot double-fire a sentinel that merely stayed put.
689
- scanSentinels() {
690
- for (const control of this.element.querySelectorAll('[data-hibiki-on*="visible->"]')) {
691
- if (control.closest('[data-controller~="hibiki"]') === this.element) {
692
- this.observer.observe(control)
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
- const action = token.slice(event.type.length + 2)
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) this.debounce(control, action, wait, fire)
746
- else fire()
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 id = setTimeout(() => {
1215
+ const run = () => {
804
1216
  byAction.delete(action)
805
1217
  this.pending.delete(id)
806
1218
  fire()
807
- }, wait)
1219
+ }
1220
+ const id = setTimeout(run, wait)
808
1221
  byAction.set(action, id)
809
- this.pending.add(id)
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
@@ -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. The dot is
48
- # allowed so a future event qualifier (keydown.enter) needs no second
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.event_name(it) }
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Hibiki
4
4
  module Rails
5
- VERSION = "0.15.0"
5
+ VERSION = "0.17.0"
6
6
  end
7
7
  end
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.15.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