hibiki_rails 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0ab077d178d71a16ddf8d0f5706179f7fbac93c4d851aae6e3ccda4ef0e4b674
4
- data.tar.gz: 409e833560a3393d2ca3c0121c2e958f3d8bb986a0b62aaaed653fe0c0f06af3
3
+ metadata.gz: e04ed317fc77afc03ef9dcb0ec2fc1f52833e23dd0700934e3bee3551c0f1549
4
+ data.tar.gz: b76c0323f43becb86476653aebd6cd58e01e1ab917c18fdf5e7e843c95f01e60
5
5
  SHA512:
6
- metadata.gz: 552f5913948b16d8ed030b1eb12c35f7f28f0f109f31807dbbaf86f9b2320c3929c959c2872800785b635b9c05229df0ebe5c218e4429b490442ee2cb81ba133
7
- data.tar.gz: 0ff3466d160bd5074a8d02530c87222fd8dcc5aeed43b033fb8ff3549decd55423286e1757511d352f39a5df47a4d60198dd0efb9fa254479febf43f1ea7d8c6
6
+ metadata.gz: c6b3b937fcaeba352b86432ceef1b4ec526e26e74509b83f39a868db656706fd4f89c64d6cd3d3805a6c5553376a1f09765dbd939ec28156bf3bb8cbcb696e45
7
+ data.tar.gz: 889c7cea1cfc1c772e6a18573e2717fbf8a569ff679a5a4f6208d2b961db6d80c4b7d8c00435e4be8783a7a57adfb22bb162d7d1624b566fedfd4f3ebbaeaf88
data/CHANGELOG.md CHANGED
@@ -4,6 +4,78 @@ The gem and the npm package are released in lockstep and share these version
4
4
  numbers — `app/assets/javascripts/hibiki.js` is a single copy served both ways,
5
5
  so importmap and bundler apps always resolve identical client code.
6
6
 
7
+ ## 0.20.0 — 2026-10-12
8
+
9
+ ### Added
10
+
11
+ **Commands.** `js` builds a list of things the browser does on an event,
12
+ with no round trip. `on` takes the list where it takes an action name.
13
+
14
+ ```erb
15
+ <%= tag.button "Menu", **on(js.toggle("#menu")) %>
16
+ <%= tag.button "Cancel", **on(js.hide("#panel").push(:cancel)) %>
17
+ ```
18
+
19
+ | Command | What it does |
20
+ |---|---|
21
+ | `show`, `hide`, `toggle` | writes the `hidden` attribute |
22
+ | `add_class`, `remove_class`, `toggle_class` | one or more class names |
23
+ | `set_attribute`, `remove_attribute`, `toggle_attribute` | an attribute by name |
24
+ | `focus`, `focus_first` | focuses the target, or the first control inside it |
25
+ | `dispatch` | fires a `CustomEvent`, with `detail:` and `bubbles:` |
26
+ | `push` | performs a channel action, with `with:` as its payload |
27
+ | `show_modal`, `close_dialog` | a `<dialog>`'s own methods |
28
+
29
+ - A target is a CSS selector for the whole document, `{ inner: selector }`
30
+ for inside the control, or `{ closest: selector }` for its nearest
31
+ ancestor. With no target a command acts on the control. `hide`, `show`,
32
+ `toggle`, `focus`, `focus_first`, `show_modal` and `close_dialog` take the
33
+ target as their argument; the others take it as `to:`.
34
+ - The commands run in order and do not wait for the server.
35
+ - `confirm:` and `debounce:` on `on` apply to the whole list. `with:` goes on
36
+ `push`. `fallback: true` needs a `push` in the list.
37
+ - A list prevents the event's default.
38
+ - A command's change lasts until the server next renders that element. Keep
39
+ state the server should know in a signal.
40
+ - Commands run only on a control inside an island.
41
+ - `set_attribute` and its siblings refuse `on*`, `style`, `srcdoc`,
42
+ `data-hibiki-*`, `data-controller` and `data-action`.
43
+
44
+ **The `hibiki-rails/commands` module.** Commands need one import, beside
45
+ your controllers:
46
+
47
+ ```js
48
+ import "hibiki-rails/commands"
49
+ ```
50
+
51
+ `hibiki:rails:install` adds it to `app/javascript/application.js`. An
52
+ existing app adds the line by hand, or runs the generator again. Without it
53
+ a command list does nothing, and the client says so once in the console.
54
+
55
+ **Dialogs the server opens.** `modal(open:)` on a `<dialog>` makes the
56
+ channel's state decide whether it is open. After each render the client
57
+ calls `showModal()` or `close()` to match.
58
+
59
+ ```erb
60
+ <%= tag.dialog id: "editor", **on(:cancel, event: :cancel).deep_merge(modal(open: form.editing?)) do %>
61
+ ```
62
+
63
+ - Use `modal` for a dialog the server must know about, such as a form it may
64
+ refuse to save. Use `js.show_modal` and `js.close_dialog` for one it need
65
+ not. Do not put both on one dialog.
66
+ - `on` now hears a dialog's `cancel` and `close` events. `cancel` is Escape;
67
+ `close` also follows `close_dialog` and a close the server asked for.
68
+ - A morph no longer removes the `open` attribute from a dialog inside an
69
+ island.
70
+ - Keep what is typed in the dialog out of the effect that renders it, or
71
+ each keystroke redraws it.
72
+
73
+ ### Changed
74
+
75
+ - A `focus` site inside a closed dialog does not count as on the page. It
76
+ takes focus when a render of this tab opens the dialog, and focus returns
77
+ to the control that opened it when the dialog closes.
78
+
7
79
  ## 0.19.0 — 2026-10-12
8
80
 
9
81
  ### Added
@@ -0,0 +1,105 @@
1
+ // hibiki-rails/commands — things a control does in the browser, with no
2
+ // round trip: show and hide, classes, attributes, focus, a DOM event, a
3
+ // dialog, and a channel action among them.
4
+ //
5
+ // Opt-in: `import "hibiki-rails/commands"` once, beside the controllers.
6
+ // The view declares a list with the `js` helper (`on(js.hide("#panel")
7
+ // .push(:cancel))`), which the client finds on the control as
8
+ // data-hibiki-do and hands to the interpreter below, in order. Like the
9
+ // rest of data-hibiki-*, the list's wire form is private to the gem.
10
+ //
11
+ // Only the names in OPS run, and an attribute command writes nothing a
12
+ // handler, a style or this gem's own wiring could ride in on. Ruby keeps
13
+ // the same two lists (Commands::OPS and Commands::REFUSED) and raises at
14
+ // render; here anything outside them is skipped. Add a command in both
15
+ // places.
16
+ //
17
+ // What a command changes lasts until the server next renders that element.
18
+ // State the server should know belongs in a signal.
19
+ import { useCommands } from "hibiki-rails"
20
+
21
+ const REFUSED = /^(on|style$|srcdoc$|data-hibiki-|data-controller$|data-action$)/i
22
+ const ATTRIBUTE = /^[a-z_:][a-z0-9_:.-]*$/i
23
+ const ACTION = /^[a-z][a-z0-9_.-]*$/i
24
+ const FOCUSABLE =
25
+ 'a[href], button:not(:disabled), input:not(:disabled):not([type="hidden"]), ' +
26
+ 'select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])'
27
+
28
+ // A selector is the app's, and a bad one must not stop the rest of a list.
29
+ const query = (root, selector, all) => {
30
+ try {
31
+ return all ? [...root.querySelectorAll(selector)] : [root.closest(selector)].filter(Boolean)
32
+ } catch {
33
+ return []
34
+ }
35
+ }
36
+
37
+ // Nothing is the control; a string is looked up in the document; `inner`
38
+ // inside the control; `closest` among its ancestors.
39
+ const targets = (control, to) => {
40
+ if (to === undefined) return [control]
41
+ if (typeof to === "string") return query(document, to, true)
42
+ if (typeof to?.inner === "string") return query(control, to.inner, true)
43
+ if (typeof to?.closest === "string") return query(control, to.closest, false)
44
+ return []
45
+ }
46
+
47
+ const classes = (names) =>
48
+ Array.isArray(names) ? names.filter((name) => typeof name === "string" && /^\S+$/.test(name)) : []
49
+ const writable = (name) => typeof name === "string" && ATTRIBUTE.test(name) && !REFUSED.test(name)
50
+
51
+ // name -> what it does to one target.
52
+ const OPS = {
53
+ show: (element) => element.removeAttribute("hidden"),
54
+ hide: (element) => element.setAttribute("hidden", ""),
55
+ toggle: (element) => element.toggleAttribute("hidden"),
56
+ add_class: (element, { names }) => element.classList.add(...classes(names)),
57
+ remove_class: (element, { names }) => element.classList.remove(...classes(names)),
58
+ toggle_class: (element, { names }) => {
59
+ for (const name of classes(names)) element.classList.toggle(name)
60
+ },
61
+ set_attribute: (element, { name, value }) => {
62
+ if (writable(name)) element.setAttribute(name, String(value ?? ""))
63
+ },
64
+ remove_attribute: (element, { name }) => {
65
+ if (writable(name)) element.removeAttribute(name)
66
+ },
67
+ toggle_attribute: (element, { name, value }) => {
68
+ if (!writable(name)) return
69
+ if (element.hasAttribute(name)) element.removeAttribute(name)
70
+ else element.setAttribute(name, String(value ?? ""))
71
+ },
72
+ dispatch: (element, { name, detail, bubbles }) => {
73
+ if (typeof name !== "string") return
74
+ element.dispatchEvent(new CustomEvent(name, { bubbles: bubbles !== false, detail }))
75
+ },
76
+ show_modal: (element) => {
77
+ if (typeof element.showModal === "function" && !element.open) element.showModal()
78
+ },
79
+ close_dialog: (element) => {
80
+ if (element.open) element.close?.()
81
+ }
82
+ }
83
+
84
+ // Focus goes to one element, so these take the first target only.
85
+ const FOCUS = {
86
+ focus: (element) => element,
87
+ focus_first: (element) => element.querySelector(FOCUSABLE)
88
+ }
89
+
90
+ useCommands((ops, control, push) => {
91
+ for (const op of ops) {
92
+ if (!Array.isArray(op)) continue
93
+ const [name, args = {}] = op
94
+ if (name === "push") {
95
+ if (typeof args.action === "string" && ACTION.test(args.action)) push(args.action, args.with ?? {})
96
+ } else if (Object.hasOwn(FOCUS, name)) {
97
+ const [first] = targets(control, args.to)
98
+ if (first) FOCUS[name](first)?.focus()
99
+ } else if (Object.hasOwn(OPS, name)) {
100
+ // With no target, close_dialog closes the dialog the control is in.
101
+ const to = name === "close_dialog" && args.to === undefined ? { closest: "dialog" } : args.to
102
+ for (const element of targets(control, to)) OPS[name](element, args)
103
+ }
104
+ }
105
+ })
@@ -64,6 +64,11 @@
64
64
  // that have no attribute, assigned on connect and after each
65
65
  // render; only the names in PROPS below, an optional "key"
66
66
  // beside them existing to change the text
67
+ // command lists data-hibiki-on="click->*" with data-hibiki-do='[["hide",
68
+ // {"to":"#panel"}],["push",{"action":"cancel"}]]': the browser
69
+ // runs the list on the event; see `useCommands` below
70
+ // modal sites data-hibiki-modal="true|false" a <dialog> shown with
71
+ // showModal() or closed, on connect and after each render
67
72
  // focus sites data-hibiki-focus="true|select" takes focus, and has its
68
73
  // text selected, when a render this tab caused inserts it
69
74
  // turbo streams data-hibiki-seq="3" the render's number, and
@@ -208,6 +213,36 @@ window.addEventListener("popstate", (event) => {
208
213
  if (key && !pushers.get(key)?.pop?.(event)) window.location.reload()
209
214
  })
210
215
 
216
+ // The command module's seam. A control whose token names `*` runs a list
217
+ // of commands (data-hibiki-do) in place of one action; what runs them is
218
+ // the opt-in module `hibiki-rails/commands`, which hands its interpreter
219
+ // over here on import. Without it the list does nothing, and says so once.
220
+ let commands
221
+ let warned = false
222
+ export function useCommands(interpreter) {
223
+ commands = interpreter
224
+ }
225
+
226
+ // A <dialog> the server declares open or closed (Helpers#modal): the state
227
+ // is a method call away from any attribute, so it is made to match here,
228
+ // on connect and after each render.
229
+ const applyModal = (dialog) => {
230
+ if (typeof dialog.showModal !== "function") return
231
+ const open = dialog.dataset.hibikiModal === "true"
232
+ if (open && !dialog.open) dialog.showModal()
233
+ else if (!open && dialog.open) dialog.close()
234
+ }
235
+
236
+ // The browser writes `open` on a dialog it shows, and no server HTML has
237
+ // it, so a morph would take it off and leave the dialog shown but closed.
238
+ // Inside an island that attribute is the browser's.
239
+ document.addEventListener("turbo:before-morph-attribute", (event) => {
240
+ const { target } = event
241
+ if (target.localName === "dialog" && event.detail?.attributeName === "open" && islandFor(target)) {
242
+ event.preventDefault()
243
+ }
244
+ })
245
+
211
246
  // The generic island CONTAINING element, or undefined. The walk performOn
212
247
  // uses, exported for the motion module's `own` policy — which reads the
213
248
  // island's trip records, so a ChannelController subclass (not in the map)
@@ -911,6 +946,9 @@ export default class HibikiController extends ChannelController {
911
946
  for (const site of this.element.querySelectorAll("[data-hibiki-props]")) {
912
947
  applyProps(site)
913
948
  }
949
+ for (const dialog of this.element.querySelectorAll("[data-hibiki-modal]")) {
950
+ applyModal(dialog)
951
+ }
914
952
 
915
953
  // Root-scoped delegation (bound to the island, not document): controls
916
954
  // inside server-replaced fragments keep working with no rebinding.
@@ -920,6 +958,13 @@ export default class HibikiController extends ChannelController {
920
958
  this.element.addEventListener(type, handler)
921
959
  return [type, handler]
922
960
  })
961
+ // A dialog's `cancel` (Escape, before it closes) and `close` do not
962
+ // bubble, so they are heard on the way down.
963
+ for (const type of ["cancel", "close"]) {
964
+ const handler = (event) => this.forward(event)
965
+ this.element.addEventListener(type, handler, true)
966
+ this.listeners.push([type, handler, true])
967
+ }
923
968
  // Keys take their own path: a key names its control by a token, not by
924
969
  // being the nearest one.
925
970
  const keydown = (event) => this.key(event)
@@ -941,7 +986,7 @@ export default class HibikiController extends ChannelController {
941
986
  // page's own marks already seen.
942
987
  this.marks = new Map()
943
988
  for (const site of this.element.querySelectorAll("[data-hibiki-focus]")) {
944
- if (site.closest(ISLAND) === this.element) this.marks.set(site, undefined)
989
+ if (this.marked(site)) this.marks.set(site, undefined)
945
990
  }
946
991
 
947
992
  // `visible` is not a DOM event, so it needs its own observer beside the
@@ -1008,8 +1053,8 @@ export default class HibikiController extends ChannelController {
1008
1053
 
1009
1054
  disconnect() {
1010
1055
  islands.delete(this.element)
1011
- for (const [type, handler] of this.listeners) {
1012
- this.element.removeEventListener(type, handler)
1056
+ for (const [type, handler, capture] of this.listeners) {
1057
+ this.element.removeEventListener(type, handler, capture)
1013
1058
  }
1014
1059
  document.removeEventListener("turbo:before-stream-render", this.streamRender)
1015
1060
  document.removeEventListener("turbo:before-render", this.pageBefore)
@@ -1158,8 +1203,11 @@ export default class HibikiController extends ChannelController {
1158
1203
  if (self && index === 0) {
1159
1204
  const twin = found(ids[0])
1160
1205
  twin.focus()
1161
- if (start != null && document.activeElement === twin) twin.setSelectionRange?.(start, end)
1162
- return
1206
+ // It may not have taken it: a field of a dialog now closed.
1207
+ if (document.activeElement === twin) {
1208
+ if (start != null) twin.setSelectionRange?.(start, end)
1209
+ return
1210
+ }
1163
1211
  }
1164
1212
  const candidates = [opener && this.twin(opener)]
1165
1213
  if (index === (self ? 1 : 0)) candidates.push(...found(ids[index]).querySelectorAll(FOCUSABLE))
@@ -1171,12 +1219,13 @@ export default class HibikiController extends ChannelController {
1171
1219
 
1172
1220
  // The control a gesture came from, or the one a render has put in its
1173
1221
  // place: the same id, else the same action and payload.
1174
- twin({ element, id, on, payload }) {
1222
+ twin({ element, id, on, payload, ops }) {
1175
1223
  if (element.isConnected) return element
1176
1224
  const twin = id
1177
1225
  ? document.getElementById(id)
1178
1226
  : [...this.element.querySelectorAll("[data-hibiki-on]")].find(
1179
- (control) => control.dataset.hibikiOn === on && control.dataset.hibikiWith === payload
1227
+ ({ dataset }) =>
1228
+ dataset.hibikiOn === on && dataset.hibikiWith === payload && dataset.hibikiDo === ops
1180
1229
  )
1181
1230
  return twin?.closest(ISLAND) === this.element ? twin : null
1182
1231
  }
@@ -1193,9 +1242,17 @@ export default class HibikiController extends ChannelController {
1193
1242
  // a listener twice is a no-op too. Props need no ownership check: assigning one twice,
1194
1243
  // from a nested island and its parent, lands the same value. Nor does
1195
1244
  // the window listener, which finds nothing of its own to fire.
1245
+ // A focus site this island counts as on the page: its own, and not
1246
+ // inside a closed dialog. So a dialog's field is new on the render that
1247
+ // opens the dialog, though its markup was there all along, and gone on
1248
+ // the one that closes it, which is when focus goes back to the opener.
1249
+ marked(site) {
1250
+ return site.closest(ISLAND) === this.element && !site.closest("dialog:not([open])")
1251
+ }
1252
+
1196
1253
  scan({ own = false, control, trail } = {}) {
1197
1254
  const found = this.element.querySelectorAll(
1198
- `[data-hibiki-on*="visible->"], [data-hibiki-props], [data-hibiki-focus], ${HOTKEYED}`
1255
+ `[data-hibiki-on*="visible->"], [data-hibiki-props], [data-hibiki-focus], [data-hibiki-modal], ${HOTKEYED}`
1199
1256
  )
1200
1257
  let hotkeys = this.element.matches(HOTKEYED)
1201
1258
  // A site is the one seen before when it is that element or, after a
@@ -1206,7 +1263,9 @@ export default class HibikiController extends ChannelController {
1206
1263
  const ids = new Set()
1207
1264
  let site
1208
1265
  for (const element of found) {
1209
- if (element.hasAttribute("data-hibiki-focus") && element.closest(ISLAND) === this.element) {
1266
+ // First: a focus site inside the dialog is on the page once it opens.
1267
+ if (element.hasAttribute("data-hibiki-modal")) applyModal(element)
1268
+ if (element.hasAttribute("data-hibiki-focus") && this.marked(element)) {
1210
1269
  marks.set(element, this.marks.get(element) ?? seen.get(element.id))
1211
1270
  if (element.id) ids.add(element.id)
1212
1271
  if (!this.marks.has(element) && !seen.has(element.id)) site ??= element
@@ -1232,7 +1291,13 @@ export default class HibikiController extends ChannelController {
1232
1291
  if (own && site && !this.unsent.has(trail?.element)) {
1233
1292
  if (control) {
1234
1293
  const { id, dataset } = control
1235
- marks.set(site, { element: control, id, on: dataset.hibikiOn, payload: dataset.hibikiWith })
1294
+ marks.set(site, {
1295
+ element: control,
1296
+ id,
1297
+ on: dataset.hibikiOn,
1298
+ payload: dataset.hibikiWith,
1299
+ ops: dataset.hibikiDo
1300
+ })
1236
1301
  }
1237
1302
  const take = () => {
1238
1303
  site.focus()
@@ -1357,7 +1422,9 @@ export default class HibikiController extends ChannelController {
1357
1422
  // Before the confirm, not after: declining must not let the form (or
1358
1423
  // a fallback control's navigation) proceed. Optional call because the
1359
1424
  // `visible` pseudo-event arrives as a plain object.
1360
- if (event.type === "submit" || fallback) event.preventDefault?.()
1425
+ // A command list is the control's whole behaviour, so its default goes.
1426
+ const listed = action === "*"
1427
+ if (event.type === "submit" || fallback || listed) event.preventDefault?.()
1361
1428
 
1362
1429
  const message = control.dataset.hibikiConfirm
1363
1430
  if (message && !window.confirm(message)) return
@@ -1366,7 +1433,7 @@ export default class HibikiController extends ChannelController {
1366
1433
  // input sends what the user finished typing rather than the first
1367
1434
  // keystroke that started the timer.
1368
1435
  const wait = Number(control.dataset.hibikiDebounce)
1369
- const fire = () => this.send(control, event, action)
1436
+ const fire = () => (listed ? this.command(control, event) : this.send(control, event, action))
1370
1437
  if (wait > 0) {
1371
1438
  const field = event.target
1372
1439
  if (event.type !== "keydown" && typed(field)) {
@@ -1411,10 +1478,32 @@ export default class HibikiController extends ChannelController {
1411
1478
  }
1412
1479
  }
1413
1480
 
1414
- send(control, event, action) {
1415
- const payload = control.dataset.hibikiWith
1416
- ? JSON.parse(control.dataset.hibikiWith)
1417
- : {}
1481
+ // The control's command list, handed to the command module with the one
1482
+ // thing only this file can do: send an action as the control's own.
1483
+ command(control, event) {
1484
+ if (!commands) {
1485
+ if (!warned) console.warn('hibiki: a control has commands, but "hibiki-rails/commands" is not imported')
1486
+ warned = true
1487
+ return
1488
+ }
1489
+ let ops
1490
+ try {
1491
+ ops = JSON.parse(control.dataset.hibikiDo)
1492
+ } catch {
1493
+ return
1494
+ }
1495
+ if (!Array.isArray(ops)) return
1496
+ commands(ops, control, (action, payload) => this.send(control, event, action, payload))
1497
+ }
1498
+
1499
+ // `given` is a pushed command's payload, where a plain action reads the
1500
+ // control's own.
1501
+ send(control, event, action, given) {
1502
+ const payload = given
1503
+ ? { ...given }
1504
+ : control.dataset.hibikiWith
1505
+ ? JSON.parse(control.dataset.hibikiWith)
1506
+ : {}
1418
1507
  if (event.type === "submit") {
1419
1508
  Object.assign(payload, formPayload(control))
1420
1509
  } else if (control.name && (event.type === "change" || event.type === "input")) {
data/config/importmap.rb CHANGED
@@ -8,3 +8,6 @@
8
8
  pin "hibiki-rails", to: "hibiki.js"
9
9
  # The opt-in motion module; it imports "hibiki-rails" by the pin above.
10
10
  pin "hibiki-rails/motion", to: "hibiki-motion.js"
11
+ # The opt-in commands module (`on(js.hide(...))`); the install generator
12
+ # imports it.
13
+ pin "hibiki-rails/commands", to: "hibiki-commands.js"
@@ -18,7 +18,7 @@ module Hibiki
18
18
 
19
19
  desc "Wires the packaged hibiki client: registers the \"hibiki\" Stimulus " \
20
20
  "controller, includes Hibiki::Rails::Helpers in ApplicationHelper, " \
21
- "and creates the ApplicationCable boilerplate."
21
+ "imports the commands module, and creates the ApplicationCable boilerplate."
22
22
 
23
23
  # Exactly the lines `stimulus:manifest:update` emits for the shim,
24
24
  # so a later manifest run converges instead of duplicating.
@@ -30,6 +30,12 @@ module Hibiki
30
30
 
31
31
  INCLUDE_LINE = " include Hibiki::Rails::Helpers\n"
32
32
 
33
+ # The opt-in module that runs `on(js....)` command lists. One line in
34
+ # the file both worlds have: the engine's pin answers it in importmap
35
+ # apps, the npm package under a bundler.
36
+ APPLICATION_JS = "app/javascript/application.js"
37
+ COMMANDS_IMPORT = %(import "hibiki-rails/commands"\n)
38
+
33
39
  # Stock Rails apps don't have these until the first `rails g
34
40
  # channel` — the hibiki:rails:* generators write their channels
35
41
  # directly, so install supplies the boilerplate they inherit.
@@ -63,6 +69,13 @@ module Hibiki
63
69
  after: /module ApplicationHelper\s*\n/
64
70
  end
65
71
 
72
+ def import_commands
73
+ return say_status :identical, APPLICATION_JS, :blue if wired?(APPLICATION_JS, COMMANDS_IMPORT.strip)
74
+ return manual_wiring(APPLICATION_JS, COMMANDS_IMPORT) unless exists?(APPLICATION_JS)
75
+
76
+ append_to_file APPLICATION_JS, COMMANDS_IMPORT
77
+ end
78
+
66
79
  # Presence is enough — an app's own ApplicationCable (customized
67
80
  # or not) is never touched.
68
81
  def create_application_cable
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Hibiki
6
+ module Rails
7
+ # What Helpers#js builds: a list of things the browser does on an event
8
+ # with no round trip, in order. Each method returns a new list, so one
9
+ # can be kept and extended:
10
+ #
11
+ # js.toggle("#menu")
12
+ # js.hide("#panel").push(:cancel)
13
+ # js.add_class("open", to: { closest: "li" })
14
+ #
15
+ # A target is a CSS selector looked up in the whole document,
16
+ # `{ inner: selector }` (inside the control), `{ closest: selector }`
17
+ # (the control's nearest ancestor), or nothing, which is the control
18
+ # itself. The client runs only the names in OPS and keeps the same list
19
+ # (OPS in hibiki-commands.js). Add a command in both places.
20
+ class Commands
21
+ OPS = %w[
22
+ show hide toggle add_class remove_class toggle_class set_attribute remove_attribute
23
+ toggle_attribute focus focus_first dispatch push show_modal close_dialog
24
+ ].freeze
25
+
26
+ # An attribute a command may not write: a handler, a style, markup, or
27
+ # the wiring of this gem and Stimulus.
28
+ REFUSED = /\A(on|style\z|srcdoc\z|data-hibiki-|data-controller\z|data-action\z)/i
29
+ ATTRIBUTE = /\A[a-z_:][a-z0-9_:.-]*\z/i
30
+ SCOPES = %i[inner closest].freeze
31
+ # A dispatched event's name; app events are often namespaced by a colon.
32
+ EVENT = /\A[a-z][a-z0-9_.:-]*\z/i
33
+
34
+ attr_reader :ops
35
+
36
+ def initialize(ops = [])
37
+ @ops = ops.freeze
38
+ freeze
39
+ end
40
+
41
+ # Show, hide or flip an element by its `hidden` attribute, which is
42
+ # also what the server renders it with.
43
+ def show(to = nil) = add("show", to:)
44
+ def hide(to = nil) = add("hide", to:)
45
+ def toggle(to = nil) = add("toggle", to:)
46
+
47
+ def add_class(names, to: nil) = add("add_class", to:, names: Commands.names(names))
48
+ def remove_class(names, to: nil) = add("remove_class", to:, names: Commands.names(names))
49
+ def toggle_class(names, to: nil) = add("toggle_class", to:, names: Commands.names(names))
50
+
51
+ def set_attribute(name, value, to: nil)
52
+ add("set_attribute", to:, name: Commands.attribute(name), value: value.to_s)
53
+ end
54
+
55
+ def remove_attribute(name, to: nil) = add("remove_attribute", to:, name: Commands.attribute(name))
56
+
57
+ # Present with `value`, or absent.
58
+ def toggle_attribute(name, value = "", to: nil)
59
+ add("toggle_attribute", to:, name: Commands.attribute(name), value: value.to_s)
60
+ end
61
+
62
+ def focus(to = nil) = add("focus", to:)
63
+
64
+ # The first control inside the target that can take focus.
65
+ def focus_first(to = nil) = add("focus_first", to:)
66
+
67
+ # A CustomEvent on the target, for the page's own JavaScript.
68
+ def dispatch(name, to: nil, detail: nil, bubbles: true)
69
+ add("dispatch", to:, name: Commands.event(name), detail:, bubbles: (false unless bubbles))
70
+ end
71
+
72
+ # A channel action, as `on(:action, with:)` sends it.
73
+ def push(action, with: nil) = add("push", action: Helpers.event_name(action), with:)
74
+
75
+ # A <dialog>, by its own methods: the browser keeps focus inside it,
76
+ # closes it on Escape and gives focus back. With no target,
77
+ # close_dialog closes the one the control is in.
78
+ def show_modal(to) = add("show_modal", to: to || raise(ArgumentError, "show_modal needs a target"))
79
+ def close_dialog(to = nil) = add("close_dialog", to:)
80
+
81
+ def push? = ops.any? { it.first == "push" }
82
+ def empty? = ops.empty?
83
+ def to_json(*) = JSON.generate(ops)
84
+
85
+ def self.names(names)
86
+ list = Array(names).flat_map { it.to_s.split }
87
+ raise ArgumentError, "a class command needs at least one class name" if list.empty?
88
+
89
+ list
90
+ end
91
+
92
+ def self.event(name)
93
+ name = name.to_s
94
+ return name if EVENT.match?(name)
95
+
96
+ raise ArgumentError, "dispatched event #{name.inspect} must match #{EVENT.inspect}"
97
+ end
98
+
99
+ def self.attribute(name)
100
+ name = name.to_s
101
+ return name if ATTRIBUTE.match?(name) && !REFUSED.match?(name)
102
+
103
+ raise ArgumentError, "attribute #{name.inspect} is not one a command may write"
104
+ end
105
+
106
+ def self.target(to)
107
+ return selector(to) if to.is_a?(String)
108
+ return { to.keys.first.to_s => selector(to.values.first) } if scoped?(to)
109
+
110
+ raise ArgumentError,
111
+ "command target #{to.inspect} must be a selector, { inner: selector } or { closest: selector }"
112
+ end
113
+
114
+ def self.scoped?(to) = to.is_a?(Hash) && to.size == 1 && SCOPES.include?(to.keys.first)
115
+
116
+ def self.selector(value)
117
+ return value if value.is_a?(String) && !value.strip.empty?
118
+
119
+ raise ArgumentError, "command selector #{value.inspect} must be a non-empty string"
120
+ end
121
+
122
+ private
123
+
124
+ def add(name, to: nil, **args)
125
+ args[:to] = Commands.target(to) unless to.nil?
126
+ self.class.new([*ops, [name, args.compact.transform_keys(&:to_s)]])
127
+ end
128
+ end
129
+ end
130
+ end
@@ -7,6 +7,7 @@ require "securerandom"
7
7
  require_relative "dom_props"
8
8
  require_relative "keys"
9
9
  require_relative "modifiers"
10
+ require_relative "commands"
10
11
 
11
12
  module Hibiki
12
13
  module Rails
@@ -223,13 +224,14 @@ module Hibiki
223
224
  # works in both worlds).
224
225
  def on(action, event: :click, with: nil, debounce: nil, confirm: nil, reset: nil,
225
226
  fallback: nil)
226
- action = Helpers.event_name(action)
227
+ extra = Modifiers.chain(action, with:, fallback:)
228
+ action = extra.empty? ? Helpers.event_name(action) : Modifiers::CHAIN
227
229
  events = Array(event).map { Helpers.event(it) }
228
230
  debounce = DEFAULT_INPUT_DEBOUNCE if debounce.nil? && events.include?("input")
229
231
  Modifiers.popstate!(debounce:, fallback:) if events.include?("popstate")
230
232
  tokens = events.map { "#{it}->#{action}" }.join(" ")
231
233
  { data: { hibiki_on: tokens }
232
- .merge(Modifiers.attributes(with:, debounce:, confirm:, reset:, fallback:)) }
234
+ .merge(Modifiers.attributes(with:, debounce:, confirm:, reset:, fallback:), extra) }
233
235
  end
234
236
 
235
237
  # Placeholder for a single reactive value: `<%= reactive :doubled, 0 %>`
@@ -279,6 +281,30 @@ module Hibiki
279
281
  { data: { hibiki_props: JSON.generate(wanted) } }
280
282
  end
281
283
 
284
+ # Commands the browser runs on the event itself, with no round trip:
285
+ #
286
+ # tag.button "Menu", **on(js.toggle("#menu"))
287
+ # tag.button "Cancel", **on(js.hide("#panel").push(:cancel))
288
+ #
289
+ # Commands lists them. `on` takes the list where it takes an action
290
+ # name; `confirm:` and `debounce:` then apply to the whole list, and
291
+ # the event's default is prevented. The page must import
292
+ # "hibiki-rails/commands". What a command changes lasts until the
293
+ # server next renders that element, so state the server should know
294
+ # belongs in a signal.
295
+ def js = Commands.new
296
+
297
+ # Open or close a <dialog> as the server's state says:
298
+ #
299
+ # tag.dialog id: "editor", **modal(open: form.editing?)
300
+ #
301
+ # After each render the client calls showModal() or close() to match.
302
+ # Render it by morph where you can: a replaced dialog is a new element
303
+ # and opens again. For a dialog the server need not know about, use
304
+ # `js.show_modal` and `js.close_dialog` instead, not both on one.
305
+ # `on(:cancel, event: :cancel)` on the dialog hears Escape.
306
+ def modal(open:) = { data: { hibiki_modal: open ? "true" : "false" } }
307
+
282
308
  # Put the cursor in this element when a render opens it:
283
309
  #
284
310
  # tag.input name: "title", value: form.title, **focus(select: true)
@@ -19,6 +19,20 @@ module Hibiki
19
19
  }.compact
20
20
  end
21
21
 
22
+ # What stands in for the action name in a token whose control runs a
23
+ # command list. No action can be named this.
24
+ CHAIN = "*"
25
+
26
+ # The attribute a command list adds, or nothing for an action name.
27
+ def self.chain(action, with:, fallback:)
28
+ return {} unless action.is_a?(Commands)
29
+ raise ArgumentError, "on(js) needs at least one command" if action.empty?
30
+ raise ArgumentError, "with: belongs on the command: js.push(:action, with: ...)" unless with.nil?
31
+ raise ArgumentError, "fallback: needs a push in the commands" if fallback && !action.push?
32
+
33
+ { hibiki_do: action.to_json }
34
+ end
35
+
22
36
  # Back is not a gesture on a control: it has nothing to wait for and
23
37
  # no native destination to fall back to.
24
38
  def self.popstate!(**modifiers)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Hibiki
4
4
  module Rails
5
- VERSION = "0.19.0"
5
+ VERSION = "0.20.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.19.0
4
+ version: 0.20.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - planetaska
@@ -76,6 +76,7 @@ extra_rdoc_files: []
76
76
  files:
77
77
  - CHANGELOG.md
78
78
  - README.md
79
+ - app/assets/javascripts/hibiki-commands.js
79
80
  - app/assets/javascripts/hibiki-motion.js
80
81
  - app/assets/javascripts/hibiki.js
81
82
  - config/importmap.rb
@@ -182,6 +183,7 @@ files:
182
183
  - lib/hibiki/rails.rb
183
184
  - lib/hibiki/rails/broadcasts.rb
184
185
  - lib/hibiki/rails/channel.rb
186
+ - lib/hibiki/rails/commands.rb
185
187
  - lib/hibiki/rails/debounce.rb
186
188
  - lib/hibiki/rails/dom_props.rb
187
189
  - lib/hibiki/rails/engine.rb