|
| 1 | +# Lesson 4: Typeahead Search |
| 2 | + |
| 3 | +In this lesson we'll add typeahead search to the existing search page. As you |
| 4 | +start typing your search query, the page will automatically return results |
| 5 | +without the need to manually submit the form. |
| 6 | + |
| 7 | + |
| 8 | + |
| 9 | +First we extend the `element` Stimulus controller with a `requestSubmit` |
| 10 | +action. This action simply submits the form the controller is attached to. |
| 11 | + |
| 12 | +```diff |
| 13 | +--- a/app/javascript/controllers/element_controller.js |
| 14 | ++++ b/app/javascript/controllers/element_controller.js |
| 15 | +@@ -4,4 +4,8 @@ export default class extends ApplicationController { |
| 16 | + replaceWithChildren() { |
| 17 | + this.element.replaceWith(...this.element.children) |
| 18 | + } |
| 19 | ++ |
| 20 | ++ requestSubmit() { |
| 21 | ++ this.element.requestSubmit() |
| 22 | ++ } |
| 23 | + } |
| 24 | +``` |
| 25 | + |
| 26 | + We'll render the `<form>` element with `[data-controller="element"]` and |
| 27 | +`[data-action="debounced:input->element#requestSubmit"]` to route every |
| 28 | +[input][] event to the `element#requestSubmit` action, which will submit the |
| 29 | +`<form>`. |
| 30 | + |
| 31 | +Since the `<form>` will be making rapid submissions and those submissions will |
| 32 | +result in a sequence of Turbo Drive visits, we'll want to render the `<form>` |
| 33 | +with [data-turbo-action="replace"][] so that we _replace_ the current History |
| 34 | +entry, instead of creating a new entry for each and every keystroke. |
| 35 | + |
| 36 | +```diff |
| 37 | +--- a/app/views/search_results/index.html.erb |
| 38 | ++++ b/app/views/search_results/index.html.erb |
| 39 | +@@ -20,7 +20,12 @@ |
| 40 | + <div class="lg:px-8"> |
| 41 | + <div class="lg:max-w-4xl"> |
| 42 | + <div class="mx-auto px-4 sm:px-6 md:max-w-2xl md:px-4 lg:px-0"> |
| 43 | +- <%= form_with model: @search, scope: "", url: false, method: :get, class: "flex items-center gap-4" do |form| %> |
| 44 | ++ <%= form_with model: @search, scope: "", url: false, method: :get, class: "flex items-center gap-4", |
| 45 | ++ data: { |
| 46 | ++ turbo_action: "replace", |
| 47 | ++ controller: "element", |
| 48 | ++ action: "debounced:input->element#requestSubmit", |
| 49 | ++ } do |form| %> |
| 50 | + <div class="relative mt-1 rounded-md shadow-sm"> |
| 51 | + <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"> |
| 52 | + <%= inline_svg_tag "icons/search.svg", class: "h-2.5 w-2.5" %> |
| 53 | +@@ -28,7 +33,8 @@ |
| 54 | + <%= form.label :query, class: "sr-only" %> |
| 55 | +``` |
| 56 | + |
| 57 | +The `debounced:input` portion of the `[data-action]` attribute refers to an |
| 58 | +event that's dispatched by the [debounced][] package, and is named after the |
| 59 | +built-in [input][] event. This can be added by running `bin/importmap pin |
| 60 | +debounced`. |
| 61 | + |
| 62 | +```diff |
| 63 | +--- a/config/importmap.rb |
| 64 | ++++ b/config/importmap.rb |
| 65 | +@@ -8,3 +8,4 @@ pin_all_from "app/javascript/controllers", under: "controllers" |
| 66 | + pin "trix" |
| 67 | + pin "@rails/actiontext", to: "actiontext.js" |
| 68 | + pin "@github/hotkey", to: "https://ga.jspm.io/npm:@github/ [email protected]/dist/index.js" |
| 69 | ++pin "debounced", to: "https://ga.jspm.io/npm:[email protected]/src/index.js" |
| 70 | +index af807eb..c8be6eb 100644 |
| 71 | +``` |
| 72 | + |
| 73 | +To preserve the query and its focus state throughout the live-search, we'll |
| 74 | +render the `<input>` element with the [data-turbo-permanent][] attribute. When |
| 75 | +combined with an `[id]` attribute that's consistent across the requesting |
| 76 | +document and the response body, Turbo Drive will carry the element instance |
| 77 | +forward through the navigation, and backward through a history restoration. |
| 78 | + |
| 79 | +If we did not set the `data-turbo-permanent` attribute, the input would keep |
| 80 | +resetting as you type. Adding the attribute preserves its state. |
| 81 | + |
| 82 | +```diff |
| 83 | +--- a/app/views/search_results/index.html.erb |
| 84 | ++++ b/app/views/search_results/index.html.erb |
| 85 | + <%= form.text_field :query, class: "w-full rounded-md border-gray-300 pl-10 text-sm placeholder:font-mono placeholder:text-sm placeholder:leading-7 placeholder:text-slate-500", |
| 86 | + placeholder: "Search", autofocus: true, |
| 87 | +- aria: {describedby: dom_id(@search, :prompt)} %> |
| 88 | ++ aria: {describedby: dom_id(@search, :prompt)}, |
| 89 | ++ data: {turbo_permanent: true} %> |
| 90 | + </div> |
| 91 | + |
| 92 | + <button class="text-sm font-bold leading-6 text-pink-500 hover:text-pink-700 active:text-pink-900"> |
| 93 | +``` |
| 94 | + |
| 95 | +We'll provide a `debounced` configuration to the page by rendering an in-line |
| 96 | +`<script type="module">` element that invokes `debounced.initialize` with a |
| 97 | +`JSON` object generated from values read from a call to |
| 98 | +[`config_for(:debounced)`][config_for]. In most environments, the `<form>` will |
| 99 | +wait 100 milliseconds between `input` events. In `test`, it'll submit |
| 100 | +immediately. |
| 101 | + |
| 102 | +```diff |
| 103 | +--- a/app/views/layouts/application.html.erb |
| 104 | ++++ b/app/views/layouts/application.html.erb |
| 105 | +@@ -9,6 +9,11 @@ |
| 106 | + |
| 107 | + <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> |
| 108 | + <%= javascript_importmap_tags %> |
| 109 | ++ <%= javascript_tag type: "module", nonce: true do %> |
| 110 | ++ import debounced from "debounced" |
| 111 | ++ |
| 112 | ++ debounced.initialize(<%= raw Rails.configuration.debounced.to_json %>) |
| 113 | ++ <% end %> |
| 114 | + </head> |
| 115 | + |
| 116 | + <body data-controller="hotkey"> |
| 117 | +``` |
| 118 | + |
| 119 | +```diff |
| 120 | +--- a/config/application.rb |
| 121 | ++++ b/config/application.rb |
| 122 | +@@ -20,5 +20,6 @@ module Botcasts |
| 123 | + # config.eager_load_paths << Rails.root.join("extras") |
| 124 | + |
| 125 | + config.active_job.queue_adapter = :good_job |
| 126 | ++ config.debounced = config_for(:debounced) |
| 127 | + end |
| 128 | + end |
| 129 | +``` |
| 130 | + |
| 131 | +```yml |
| 132 | +# config/debounced.yml |
| 133 | +shared: |
| 134 | + input: |
| 135 | + wait: 100 |
| 136 | + |
| 137 | +test: |
| 138 | + input: |
| 139 | + wait: 0 |
| 140 | +``` |
| 141 | +
|
| 142 | +[input]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/input_event |
| 143 | +[data-turbo-permanent]: https://turbo.hotwired.dev/handbook/building#persisting-elements-across-page-loads |
| 144 | +[debounced]: https://github.com/hopsoft/debounced#why |
| 145 | +[config_for]: https://edgeapi.rubyonrails.org/classes/Rails/Application.html#method-i-config_for |
| 146 | +[data-turbo-action="replace"]: https://turbo.hotwired.dev/handbook/drive#application-visits |
0 commit comments