Lazy Hotkeys for Lazy People
Define hotkeys in ERB. Handle navigation, requests, and DOM changes without writing JS.
Install
1. Add the gem:
bundle add lazy_hotkeysOr add to your Gemfile:
gem "lazy_hotkeys", "~> 0.1"Then run bundle install
2. Run the generator:
rails generate lazy_hotkeys:installThis copies lazy_hotkeys.js to app/javascript/
3. Load it:
Importmap:
pin "lazy_hotkeys", to: "lazy_hotkeys.js"Then import it:
// app/javascript/application.js
import "lazy_hotkeys"esbuild/rollup/webpack or Vite:
Import with relative path in your entry point:
// app/javascript/application.js (or entrypoints/application.js in Vite)
import "./lazy_hotkeys"Done. Hotkeys work now. (hopefully)
1. Hotkeys
Press keys. Things happen. Stop pretending you like writing JavaScript.
Navigate Somewhere
<%= hotkey("g i", visit: "/inbox") %>
<%= hotkey("ctrl+n", visit: new_post_path) %>Press g then i. You're in your inbox. Magic? No. Just less suffering.
Send a Request
<%= hotkey("ctrl+s", to: "/save", method: :post) %>
<%= hotkey("ctrl+d", to: post_path(@post), method: :delete) %>Ctrl+S sends a POST. Ctrl+D deletes. Your mouse is crying. Good.
Dispatch an Action
<%= hotkey("ctrl+k", action: "open-command-palette") %>// Some Stimulus controller, somewhere
document.addEventListener('lazy-hotkeys:action', (e) => {
if (e.detail.action === 'open-command-palette') {
this.open();
}
});For when you absolutely must write JavaScript.
Chain Multiple Actions
<%= hotkey("ctrl+s", chain: [
{ type: "dom", target: "#status", set_text: "Saving..." },
{ type: "request", to: "/save", method: "post" },
{ type: "dom", target: "#status", set_text: "Saved!" }
]) %>One key. Multiple actions. Sequential.
2. DOM Manipulation
Show/Hide Things
<%= hotkey("?", dom: { target: "#help", toggle_class: "hidden" }) %>
<%= hotkey("esc", dom: { target: ".modal", add_class: "hidden" }) %>
<%= hotkey("ctrl+h", dom: { target: "#sidebar", remove_class: "collapsed" }) %>Change Text
<%= hotkey("ctrl+shift+c", dom: {
target: "#counter",
set_text: "9999"
}) %>Click Things
<%= hotkey("ctrl+enter", dom: { target: "form button", click: true }) %>
<%= hotkey("/", dom: { target: "#search-input", focus: true }) %>Your hands never leave the keyboard. Your mouse collects dust. Evolution.
Set Attributes
<%= hotkey("ctrl+d", dom: { target: "#mode", set_attr: { "data-theme": "dark" }}) %>Use a Template (For more complex HTML)
<%= hotkey("ctrl+p", dom: { target: "#preview", replace_with: "template" }) do %>
<div class="preview-panel">
<h3>Preview</h3>
<p>Your content here</p>
</div>
<% end %>Press the key. The template replaces the target. No innerHTML. No XSS. Just works.
Options
| Option | Description |
|---|---|
visit: "/path" |
Navigate to URL (Turbo or regular) |
to: "/endpoint" |
Send request (GET/POST/PATCH/DELETE) |
method: :post |
HTTP method (with to:) |
params: { ... } |
Request params (with to:) |
action: "name" |
Dispatch custom event |
dom: { ... } |
Manipulate DOM (see below) |
chain: [...] |
Multiple actions in sequence |
scope: "#form" |
Only works when element exists |
prevent_default: false |
Allow browser default (default: true) |
same_origin: false |
Allow cross-origin (default: true) |
hint: "Save" |
Tooltip/hint text |
DOM Options
| Option | Description | Example |
|---|---|---|
target: "#id" |
CSS selector | Required |
click: true |
Click the element | { target: "button", click: true } |
focus: true |
Focus the element | { target: "input", focus: true } |
set_text: "..." |
Change text (safe) | { target: "#status", set_text: "Done" } |
add_class: "..." |
Add CSS class | { target: "#box", add_class: "active" } |
remove_class: "..." |
Remove CSS class | { target: "#box", remove_class: "hidden" } |
toggle_class: "..." |
Toggle CSS class | { target: "#menu", toggle_class: "open" } |
set_attr: { k: v } |
Set attributes (whitelisted) | { target: "#mode", set_attr: { "data-theme": "dark" } } |
remove_attr: "..." |
Remove attribute | { target: "#input", remove_attr: "disabled" } |
replace_with: "template" |
Replace with template content | See template example above |
Cross-Origin
By default, requests to other domains are blocked:
<%# This works %>
<%= hotkey("ctrl+s", to: "/save") %>
<%# This is blocked %>
<%= hotkey("ctrl+x", to: "https://github.com/Plan-Vert/open-letter") %>
<%# This works (you asked for it) %>
<%= hotkey("?", visit: "https://discord.gg/BUtwjJTwxt", same_origin: false) %>javascript:, data:, and file: URLs are always blocked, even with same_origin: false.
Attribute whitelist:
Check lazy_hotkeys.js and adjust attribute whitelist to your needs, define only what you need.
Config
Minimal config at the top of lazy_hotkeys.js:
const CFG = {
normalizeCmdToCtrl: true, // Cmd on Mac = Ctrl on Windows
skipInInputs: true, // Don't fire when typing in inputs
sequenceTimeoutMs: 800 // How long to wait for sequences like "g i"
};Change these values directly in the file. That's it.
Requirements
Rails 5.1+ (needs tag.template helper)
License
MIT