Skip to content

Commit 3015f74

Browse files
stevepolitodesignseanpdoyle
authored andcommitted
Lesson 4: Typeahead Search
This commit extends the `element` Stimulus controller with a `requestSubmit` action. We'll render the `<form>` element with `[data-controller="element"]` and `[data-action="debounced:input->element#requestSubmit"]` to route every [input][] event to the `element#requestSubmit` action, which will submit the `<form>`. The `debounced:input` portion of the `[data-action]` attribute is refers to an event that's dispatched by the [debounced][] package, and is named after the built-in [input][] event. To preserve the query and its focus state throughout the live-search, we'll render the `<input>` element with the [data-turbo-permanent][] attribute. When combined with an `[id]` attribute that's consistent across the requesting document and the response body, Turbo Drive will carry the element instance forward through the navigation, and backward through a history restoration. We'll provide a `debounced` configuration to the page by rendering an in-line `<script type="module">` element that invokes `debounced.initialize` with a `JSON` object generated from values read from a call to [`config_for(:debounced)`][config_for]. In most environments, the `<form>` will wait 100 milliseconds between `input` events. In `test`, it'll submit immediately. Since the `<form>` will be making rapid submissions and those submissions will result in a sequence of Turbo Drive visits, we'll want to render the `<form>` with [data-turbo-action="replace"][] so that we _replace_ the current History entry, instead of creating a new entry for each and every keystroke. [input]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/input_event [data-turbo-permanent]: https://turbo.hotwired.dev/handbook/building#persisting-elements-across-page-loads [debounced]: https://github.com/hopsoft/debounced#why [config_for]: https://edgeapi.rubyonrails.org/classes/Rails/Application.html#method-i-config_for [data-turbo-action="replace"]: https://turbo.hotwired.dev/handbook/drive#application-visits
1 parent 36abf80 commit 3015f74

File tree

12 files changed

+221
-5
lines changed

12 files changed

+221
-5
lines changed

.tags.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,5 @@
66
commit: "Lesson 2"
77
- tag: lesson-3
88
commit: "Lesson 3"
9+
- tag: lesson-4
10+
commit: "Lesson 4"

app/javascript/controllers/element_controller.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,8 @@ export default class extends ApplicationController {
44
replaceWithChildren() {
55
this.element.replaceWith(...this.element.children)
66
}
7+
8+
requestSubmit() {
9+
this.element.requestSubmit()
10+
}
711
}

app/views/layouts/application.html.erb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99

1010
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
1111
<%= javascript_importmap_tags %>
12+
<%= javascript_tag type: "module", nonce: true do %>
13+
import debounced from "debounced"
14+
15+
debounced.initialize(<%= raw Rails.configuration.debounced.to_json %>)
16+
<% end %>
1217
</head>
1318

1419
<body data-controller="hotkey">

app/views/search_results/index.html.erb

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,21 @@
2020
<div class="lg:px-8">
2121
<div class="lg:max-w-4xl">
2222
<div class="mx-auto px-4 sm:px-6 md:max-w-2xl md:px-4 lg:px-0">
23-
<%= form_with model: @search, scope: "", url: false, method: :get, class: "flex items-center gap-4" do |form| %>
23+
<%= form_with model: @search, scope: "", url: false, method: :get, class: "flex items-center gap-4",
24+
data: {
25+
turbo_action: "replace",
26+
controller: "element",
27+
action: "debounced:input->element#requestSubmit",
28+
} do |form| %>
2429
<div class="relative mt-1 rounded-md shadow-sm">
2530
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
2631
<%= inline_svg_tag "icons/search.svg", class: "h-2.5 w-2.5" %>
2732
</div>
2833
<%= form.label :query, class: "sr-only" %>
2934
<%= 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",
3035
placeholder: "Search", autofocus: true,
31-
aria: {describedby: dom_id(@search, :prompt)} %>
36+
aria: {describedby: dom_id(@search, :prompt)},
37+
data: {turbo_permanent: true} %>
3238
</div>
3339

3440
<button class="text-sm font-bold leading-6 text-pink-500 hover:text-pink-700 active:text-pink-900">

config/application.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,6 @@ class Application < Rails::Application
2020
# config.eager_load_paths << Rails.root.join("extras")
2121

2222
config.active_job.queue_adapter = :good_job
23+
config.debounced = config_for(:debounced)
2324
end
2425
end

config/debounced.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
shared:
2+
input:
3+
wait: 100
4+
5+
test:
6+
input:
7+
wait: 0

config/importmap.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@
88
pin "trix"
99
pin "@rails/actiontext", to: "actiontext.js"
1010
pin "@github/hotkey", to: "https://ga.jspm.io/npm:@github/[email protected]/dist/index.js"
11+
pin "debounced", to: "https://ga.jspm.io/npm:[email protected]/src/index.js"

lessons/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ anytime.
1616
* [Lesson 1: Our first lines of JavaScript](./lesson-1.md)
1717
* [Lesson 2: The Audio Player](./lesson-2.md)
1818
* [Lesson 3: Infinite Scroll](./lesson-3.md)
19+
* [Lesson 4: Typeahead Search](./lesson-4.md)

lessons/assets/lesson-4/demo.gif

1.23 MB
Loading

lessons/lesson-4.md

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
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+
![Demo of typeahead search](./assets/lesson-4/demo.gif)
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

Comments
 (0)