Resources

Download Source Code

Summary

# Terminal
bin/rails g stimulus hotkey
bin/rails g stimulus filter

# app/javascript/controllers/hotkey_controller.js
import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="hotkey"
export default class extends Controller {
  static targets = ["field"]
  static values = { key: { type: String, default: "/" } }

  connect() {
    this.onKeydown = this.onKeydown.bind(this)
    document.addEventListener("keydown", this.onKeydown)
  }

  disconnect() {
    document.removeEventlistener("keydown", this.onKeydown)
  }

  onKeydown(event) {
    if (event.key !== this.keyValue) return

    const tag = document.activeElement?.tagName

    if (tag === "INPUT" || tag === "TEXTAREA") return

    event.preventDefault()
    this.fieldTarget.focus()
    this.fieldTarget.select()
  }
}

# app/javascript/controllers/filter_controller.js
import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="filter"
export default class extends Controller {
  static targets = ["form", "input", "clear"]
  static values = { debounce: { type: Number, default: 200 } }

  connect() {
    this.timeout = null
    this.refreshClearState()
  }

  disconnect() {
    clearTimeout(this.timeout)
  }

  submit() {
    clearTimeout(this.timeout)
    this.timeout = setTimeout(() => {
      this.formTarget.requestSubmit()
      this.refreshClearState()
    }, this.debounceValue)
  }

  clear() {
    this.inputTarget.value = ""
    this.inputTarget.focus()
    this.formTarget.requestSubmit()
    this.refreshClearState()
  }

  refreshClearState() {
    if (!this.hasClearTarget) return
    this.clearTarget.disabled = this.inputTarget.value.trim() === ""
  }
}

# app/views/episodes/index.html.erb
<% content_for :title, "Episodes" %>

<header class="mb-6">
  <h1 class="font-bold text-3xl text-slate-900">Episodes</h1>
</header>

<div data-controller="filter hotkey">

  <%= form_with url: episodes_path, method: :get,
                html: { role: "search", class: "flex gap-2 mb-6" },
                data: { turbo_frame: "episodes_list", filter_target: "form" } do |f| %>
    <%= f.search_field :q,
          value: @query,
          placeholder: "Search title, description, guest, or tag… (press / to focus)",
          autocomplete: "off",
          data: { hotkey_target: "field", filter_target: "input", action: "input->filter#submit" },
          class: "flex-1 rounded border border-slate-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" %>

    <button type="button"
            data-filter-target="clear"
            data-action="filter#clear"
            class="rounded border border-slate-300 bg-white px-3 py-2 text-slate-700 hover:bg-slate-100 disabled:opacity-40"
            <%= "disabled" if @query.blank? %>>
      Clear
    </button>
  <% end %>

  <%= turbo_frame_tag "episodes_list" do %>
    <%= render "list", episodes: @episodes, query: @query %>
  <% end %>
</div>