Ten Years of Frontend

Episode #561 by Teacher's Avatar David Kimura

Summary

In this episode, we look at where we were years ago and the journey where we have landed today. Over the past 10 years, much has changed with our approach to client interactions and in the episode we explore my favorite and current approach.
rails hotwire turbo stimulusjs 17:29

Chapters

  • Introduction (0:00)
  • Base application (2:47)
  • Base app review (4:33)
  • Stimulus Hotkey (5:26)
  • Turbo Frame - Results List (8:56)
  • Stimulus - Filters (10:56)
  • Final Thoughts (16:05)

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>