hibiki_rails 0.16.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: d7bfeee9d4e44c3404ac6582548e0bdb46ae139cfa26eec2c71b7d8b95c40c77
4
- data.tar.gz: ab0505b42c8450e0a8014f94c380ed2f3e995448a9cf8cfc8ed16f4eb450d524
3
+ metadata.gz: fbcb87c658d3e83cc6c10f216a69e78266878aa8396ed9662e3f06d8c6213027
4
+ data.tar.gz: d6a66d149293d26e43edf4cd31ec20eb5886cc0d3db2b935b5b0c3c333965292
5
5
  SHA512:
6
- metadata.gz: 6c032ed168913d5c4cc0a56afdf8b6a58cd89ad61581acfda17fc6e2a8030f0ebe4a81f35e0f4ec58691b79df2e6831be4bfa75f4d850db2ff334d9504a30bbb
7
- data.tar.gz: 79144e77c309815099be858e605ddad09c6a655ebc2b66f3e20c68fdfe6cf0dceca295bc418304ea6fb0865dfda732a9a4704018029fd8f52a39f21148e15532
6
+ metadata.gz: 0e027bd60c62abdf4d269b94cd38f0e008889b42d205b9f2d98d737f1455acd60f467cf9951a5a35f77bbc3ee02120b8e5f6397fc43dc9b7dc7842d91c73372e
7
+ data.tar.gz: 28a6058a2a235ef23e02d52b5dbdf7177f97b6ba96971d9c72ea0d0302d6eaf380854b2e283069177b2038a06a2d45af540bf3b929a62f53baa7144589c33bc3
data/CHANGELOG.md CHANGED
@@ -4,6 +4,86 @@ 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
+
7
87
  ## 0.16.0 — 2026-10-07
8
88
 
9
89
  ### Added
@@ -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
@@ -61,6 +64,8 @@
61
64
  // that have no attribute, assigned on connect and after each
62
65
  // render; only the names in PROPS below, an optional "key"
63
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
64
69
  //
65
70
  // The client writes the protocol's other half at runtime — read-only to
66
71
  // app code, addressed to app CSS:
@@ -89,6 +94,30 @@
89
94
  // Everything that is not "which event" is a sibling attribute, so the
90
95
  // token grammar never has to grow.
91
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
+ //
92
121
  // App JS reaching the graph — the ONE public seam. A gesture that needs
93
122
  // script (drag-and-drop, a third-party widget) fires its action through
94
123
  // the island's OWN subscription: `perform(action, payload)` on the island
@@ -232,9 +261,15 @@ export class ChannelController extends Controller {
232
261
  // busyCeiling ms before a trip is declared stalled rather than silently
233
262
  // cleared. On a bad link "we lost it" beats "nothing
234
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.
235
269
  static busyDelay = 150
236
270
  static busyGrace = 60
237
271
  static busyCeiling = 10000
272
+ static focusGrace = 500
238
273
 
239
274
  async connect() {
240
275
  this.prepareTransport()
@@ -254,6 +289,7 @@ export class ChannelController extends Controller {
254
289
  this.seq = 0
255
290
  this.renders = 0
256
291
  this.busy = new Map()
292
+ this.settledAt = -Infinity
257
293
  this.lastControl = null
258
294
  this.queued = []
259
295
  this.setState("connecting")
@@ -447,6 +483,7 @@ export class ChannelController extends Controller {
447
483
  clearTimeout(record.ceiling)
448
484
  clearTimeout(record.grace)
449
485
  this.busy.delete(seq)
486
+ this.settledAt = performance.now()
450
487
  record.control?.removeAttribute("data-hibiki-busy")
451
488
  if (this.busy.size === 0) this.hideBusy()
452
489
  }
@@ -464,6 +501,13 @@ export class ChannelController extends Controller {
464
501
  this.element.removeAttribute("aria-busy")
465
502
  }
466
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
+
467
511
  // Say so rather than clearing silently: on a bad link the honest report
468
512
  // is "we lost it", and a cleared indicator claims the opposite.
469
513
  stall(seq) {
@@ -602,6 +646,88 @@ const PROPS = {
602
646
  }
603
647
  const appliedProps = new WeakMap()
604
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
+
605
731
  function applyProps(element) {
606
732
  const text = element.getAttribute("data-hibiki-props")
607
733
  let wanted
@@ -663,12 +789,29 @@ export default class HibikiController extends ChannelController {
663
789
  this.element.addEventListener(type, handler)
664
790
  return [type, handler]
665
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)
666
799
 
667
800
  // Debounce bookkeeping: a WeakMap keyed by control (so detached
668
- // elements don't pin memory) plus a flat set of live timeouts, which is
669
- // 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.
670
804
  this.timers = new WeakMap()
671
- 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
+ }
672
815
 
673
816
  // `visible` is not a DOM event, so it needs its own observer beside the
674
817
  // delegated listeners. Always on, never a pluggable module: an
@@ -692,6 +835,7 @@ export default class HibikiController extends ChannelController {
692
835
  // is a real per-mutation cost paid by every app on it.
693
836
  this.streamRender = (event) => {
694
837
  const render = event.detail.render
838
+ const arrival = this.arrival()
695
839
  event.detail.render = async (streamElement) => {
696
840
  await render(streamElement)
697
841
  // The Turbo transport's paint. Counting it here is what lets an ack
@@ -700,14 +844,23 @@ export default class HibikiController extends ChannelController {
700
844
  // — which at worst settles an already-acked trip a few ms early,
701
845
  // never one that has not been acked at all.
702
846
  this.renders++
703
- this.scan()
847
+ this.scan(arrival)
704
848
  }
705
849
  }
706
850
  document.addEventListener("turbo:before-stream-render", this.streamRender)
707
851
  // A refresh stream morphs the page after its own render has returned,
708
852
  // so the wrapper above scans too early for it; turbo:render follows
709
853
  // the morph.
710
- this.pageRender = () => this.scan()
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)
711
864
  document.addEventListener("turbo:render", this.pageRender)
712
865
 
713
866
  await this.openSubscription()
@@ -721,9 +874,13 @@ export default class HibikiController extends ChannelController {
721
874
  this.element.removeEventListener(type, handler)
722
875
  }
723
876
  document.removeEventListener("turbo:before-stream-render", this.streamRender)
877
+ document.removeEventListener("turbo:before-render", this.pageBefore)
724
878
  document.removeEventListener("turbo:render", this.pageRender)
725
- for (const id of this.pending) clearTimeout(id)
879
+ window.removeEventListener("keydown", this.hotkeys)
880
+ for (const id of this.pending.keys()) clearTimeout(id)
726
881
  this.pending.clear()
882
+ this.unsent.clear()
883
+ this.marks.clear()
727
884
  this.observer.disconnect()
728
885
  super.disconnect()
729
886
  }
@@ -731,24 +888,102 @@ export default class HibikiController extends ChannelController {
731
888
  // The other swap point: hibiki's own transmit transport. A held swap
732
889
  // hands back a promise; the replacement sentinel exists only after it.
733
890
  received(data) {
891
+ const arrival = this.arrival()
734
892
  const swapped = super.received(data)
735
893
  if (!data.html) return
736
- if (swapped?.then) swapped.then(() => this.scan())
737
- else this.scan()
738
- }
739
-
740
- // What a render leaves for the client to finish, in one walk of the
741
- // island: assign every prop site's properties, and observe every
742
- // `visible->` sentinel this island owns. observe() is a no-op for an
743
- // element already being observed, so re-scanning is cheap and cannot
744
- // double-fire a sentinel that merely stayed put. Props need no ownership
745
- // check: assigning one twice, from a nested island and its parent, lands
746
- // the same value.
747
- scan() {
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 }
916
+ }
917
+
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 } = {}) {
748
969
  const found = this.element.querySelectorAll(
749
- '[data-hibiki-on*="visible->"], [data-hibiki-props]'
970
+ `[data-hibiki-on*="visible->"], [data-hibiki-props], [data-hibiki-focus], ${HOTKEYED}`
750
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
751
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
985
+ }
986
+ hotkeys ||= element.dataset.hibikiOn?.includes("@window")
752
987
  if (element.hasAttribute("data-hibiki-props")) applyProps(element)
753
988
  if (
754
989
  element.dataset.hibikiOn?.includes("visible->") &&
@@ -757,6 +992,75 @@ export default class HibikiController extends ChannelController {
757
992
  this.observer.observe(element)
758
993
  }
759
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
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)
760
1064
  }
761
1065
 
762
1066
  // DOM → server: forward a control's event as a channel action.
@@ -776,8 +1080,11 @@ export default class HibikiController extends ChannelController {
776
1080
  .split(/\s+/)
777
1081
  .find((t) => t.startsWith(`${event.type}->`))
778
1082
  if (!token) return
779
- const action = token.slice(event.type.length + 2)
1083
+ this.run(control, event, token.slice(event.type.length + 2))
1084
+ }
780
1085
 
1086
+ // From a control's matched token to the wire.
1087
+ run(control, event, action) {
781
1088
  // A fallback control's native behavior IS the degraded path: unless
782
1089
  // the island is `ready`, stand aside — no perform, no queueing — and
783
1090
  // the browser follows the href or submits the form to its own
@@ -807,8 +1114,48 @@ export default class HibikiController extends ChannelController {
807
1114
  // keystroke that started the timer.
808
1115
  const wait = Number(control.dataset.hibikiDebounce)
809
1116
  const fire = () => this.send(control, event, action)
810
- if (wait > 0) this.debounce(control, action, wait, fire)
811
- 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
+ }
812
1159
  }
813
1160
 
814
1161
  send(control, event, action) {
@@ -865,13 +1212,14 @@ export default class HibikiController extends ChannelController {
865
1212
  clearTimeout(previous)
866
1213
  this.pending.delete(previous)
867
1214
  }
868
- const id = setTimeout(() => {
1215
+ const run = () => {
869
1216
  byAction.delete(action)
870
1217
  this.pending.delete(id)
871
1218
  fire()
872
- }, wait)
1219
+ }
1220
+ const id = setTimeout(run, wait)
873
1221
  byAction.set(action, id)
874
- this.pending.add(id)
1222
+ this.pending.set(id, run)
875
1223
  }
876
1224
  }
877
1225
 
@@ -5,6 +5,7 @@ require "json"
5
5
  require "securerandom"
6
6
 
7
7
  require_relative "dom_props"
8
+ require_relative "keys"
8
9
 
9
10
  module Hibiki
10
11
  module Rails
@@ -46,9 +47,8 @@ module Hibiki
46
47
 
47
48
  # Event and action names share the hibiki_on attribute, which is a
48
49
  # whitespace-separated token list — a name carrying a space or an
49
- # arrow would silently change what the client dispatches. The dot is
50
- # allowed so a future event qualifier (keydown.enter) needs no second
51
- # grammar change.
50
+ # arrow would silently change what the client dispatches. Key events
51
+ # (keydown.enter) have their own grammar; see Keys.
52
52
  EVENT_NAME = /\A[a-z][a-z0-9_.-]*\z/i
53
53
 
54
54
  # Per-keystroke round trips are the failure mode #on exists to avoid,
@@ -103,6 +103,13 @@ module Hibiki
103
103
  name
104
104
  end
105
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
+
106
113
  # The island root: one channel subscription per island, identified by
107
114
  # a per-page-load cid (each tab is its own graph). `channel` is the
108
115
  # channel class or its name as a string.
@@ -125,8 +132,7 @@ module Hibiki
125
132
  # override `channel` or `cid` through this hash.
126
133
  def hibiki_island(channel, cid:, params: nil)
127
134
  channel_name = channel.is_a?(Class) ? channel.name : channel.to_s
128
- data = { controller: "hibiki", hibiki_channel_value: channel_name,
129
- hibiki_cid_value: cid }
135
+ data = { controller: "hibiki", hibiki_channel_value: channel_name, hibiki_cid_value: cid }
130
136
  data[:hibiki_params_value] = JSON.generate(params) unless params.nil?
131
137
  { data: }
132
138
  end
@@ -169,6 +175,13 @@ module Hibiki
169
175
  #
170
176
  # on(:load_more, event: %i[click visible], with: { shown: rows.size })
171
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
+ #
172
185
  # `with:` is a hash sent as the action's payload. The client adds
173
186
  # event-derived data on top: a changed control contributes
174
187
  # `{ name => value }` — a checkbox its checked state, a multi-select
@@ -206,7 +219,7 @@ module Hibiki
206
219
  def on(action, event: :click, with: nil, debounce: nil, confirm: nil, reset: nil,
207
220
  fallback: nil)
208
221
  action = Helpers.event_name(action)
209
- events = Array(event).map { Helpers.event_name(it) }
222
+ events = Array(event).map { Helpers.event(it) }
210
223
  debounce = DEFAULT_INPUT_DEBOUNCE if debounce.nil? && events.include?("input")
211
224
  tokens = events.map { "#{it}->#{action}" }.join(" ")
212
225
  { data: { hibiki_on: tokens }
@@ -260,6 +273,18 @@ module Hibiki
260
273
  { data: { hibiki_props: JSON.generate(wanted) } }
261
274
  end
262
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
+
263
288
  private
264
289
 
265
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.16.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.16.0
4
+ version: 0.17.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - planetaska
@@ -187,6 +187,7 @@ files:
187
187
  - lib/hibiki/rails/engine.rb
188
188
  - lib/hibiki/rails/graph_actor.rb
189
189
  - lib/hibiki/rails/helpers.rb
190
+ - lib/hibiki/rails/keys.rb
190
191
  - lib/hibiki/rails/nested_actions.rb
191
192
  - lib/hibiki/rails/reactive_form.rb
192
193
  - lib/hibiki/rails/registry.rb