Search Form
View template source
gem "importmap-rails" unless File.read("Gemfile").include?("importmap")
gem "perron" unless File.read("Gemfile").include?("perron")
after_bundle do
unless File.exist?("config/importmap.rb")
rails_command "importmap:install"
end
unless File.exist?("config/initializers/perron.rb")
rails_command "perron:install"
end
route "resource :search, module: :perron, path: \"search.json\", only: %%w[show]"
insert_into_file "config/initializers/perron.rb", "\n config.search_scope = []\n", after: "Perron.configure do |config|"
create_file "app/javascript/components/index.js", <<~JS, skip: true
import "components/search_form"
JS
application_js_path = "app/javascript/application.js"
if File.exist?(application_js_path)
insert_into_file application_js_path, "\nimport \"components\"\n", after: /\A(?:import .+\n)*/
else
create_file application_js_path, <<~JS
import "components"
JS
end
unless File.exist?("config/importmap.rb")
say "Warning: importmap.rb not found!", :yellow
say "Please set up importmap-rails first by running:"
say " rails importmap:install"
say "Or use your preferred JavaScript set up."
return
end
run "bin/importmap pin minisearch"
run "bin/importmap pin tinykeys"
insert_into_file "config/importmap.rb", after: /\A.*pin .+\n+/m do
"\npin_all_from \"app/javascript/components\", under: \"components\"\npin_all_from \"app/javascript/helpers\", under: \"helpers\"\n"
end
create_file "app/javascript/components/search_form.js", <<~'TEXT', force: true
import MiniSearch from "minisearch"
import { excerpt, highlight } from "helpers/search"
export default class SearchForm extends HTMLElement {
#clickOutside = null
#unsubscribe = null
connectedCallback() {
this.debounceTimeout = null
this.selectedIndex = -1
this.#input.addEventListener("focus", () => {
this.setAttribute("data-focused", "")
this.#initialize()
})
this.#input.addEventListener("blur", () => {
this.removeAttribute("data-focused")
})
this.#setupClickOutside()
if (!this.#submitButton) {
this.#input.addEventListener("input", (event) => this.#debounce(event.target.value))
} else {
this.#submitButton.addEventListener("click", (event) => {
event.preventDefault()
this.#search(this.#input.value)
})
}
this.#input.addEventListener("keydown", (event) => this.#navigate(event))
if (this.hasAttribute("data-shortcut")) this.#setupShortcut()
}
disconnectedCallback() {
if (this.debounceTimeout) clearTimeout(this.debounceTimeout)
if (this.#unsubscribe) this.#unsubscribe()
document.removeEventListener("mousedown", this.#clickOutside)
}
// private
#setupClickOutside() {
this.#clickOutside = (event) => {
if (!this.contains(event.target)) {
this.#close()
}
}
document.addEventListener("mousedown", this.#clickOutside)
}
async #setupShortcut() {
try {
const imported = await import("tinykeys")
const tinykeys = imported.default || imported.tinykeys || imported
this.#unsubscribe = tinykeys(window, {
[this.getAttribute("data-shortcut")]: (event) => {
event.preventDefault()
this.#input.focus()
}
})
} catch {}
}
#navigate(event) {
if (event.key === "Escape") {
event.preventDefault()
this.#close()
return
}
if (!this.hasAttribute("data-open")) return
const results = this.#resultsContainer.querySelectorAll("li a")
if (results.length === 0) return
switch (event.key) {
case "ArrowDown":
event.preventDefault()
this.selectedIndex = Math.min(this.selectedIndex + 1, results.length - 1)
this.#updateSelection(results)
break
case "ArrowUp":
event.preventDefault()
this.selectedIndex = Math.max(this.selectedIndex - 1, -1)
this.#updateSelection(results)
break
case "Enter":
event.preventDefault()
if (this.selectedIndex >= 0) results[this.selectedIndex].click()
break
}
}
#debounce(query) {
if (this.debounceTimeout) clearTimeout(this.debounceTimeout)
this.debounceTimeout = setTimeout(() => this.#search(query), 150)
}
async #initialize() {
if (this.index) return
this.setAttribute("aria-busy", "true")
try {
const response = await fetch(this.#searchEndpoint)
this.index = await response.json()
this.miniSearch = new MiniSearch(this.#config)
this.miniSearch.addAll(this.index)
} catch (error) {
console.error("Failed to load search index:", error)
} finally {
this.removeAttribute("aria-busy")
}
}
#search(query) {
if (query.trim().length < 2) {
this.#close()
return
}
const results = this.miniSearch.search(query)
this.#render(results, query)
}
#close() {
this.removeAttribute("data-open")
this.removeAttribute("data-focused")
this.removeAttribute("data-results")
this.removeAttribute("data-empty")
this.removeAttribute("data-results-count")
this.#resultsContainer.innerHTML = ""
}
#render(results, query = "") {
this.selectedIndex = -1
if (results.length === 0) {
this.#renderEmpty()
return
}
const groupBy = this.getAttribute("data-group-by")
const html = groupBy ? this.#renderGrouped(results, groupBy, query) : results.map(result => this.#renderItem(result, query)).join("")
this.#resultsContainer.innerHTML = html
this.#resultsContainer.querySelectorAll("li a").forEach(link => {
link.addEventListener("mouseenter", () => {
this.selectedIndex = -1
this.#resultsContainer.querySelectorAll("li[data-selected]").forEach(item => item.removeAttribute("data-selected"))
})
})
this.removeAttribute("data-empty")
this.setAttribute("data-results-count", results.length)
this.setAttribute("data-results", "")
this.setAttribute("data-open", "")
}
#renderEmpty() {
const emptyContainer = this.querySelector("[data-slot='empty']")
if (emptyContainer) {
emptyContainer.innerHTML = this.#emptyMessage
emptyContainer.removeAttribute("hidden")
}
this.setAttribute("data-results-count", "0")
this.setAttribute("data-empty", "")
this.setAttribute("data-open", "")
}
#renderGrouped(results, groupBy, query) {
const grouped = results.reduce((group, result) => {
const key = result[groupBy] || "Other"
if (!group[key]) {
group[key] = []
}
group[key].push(result)
return group
}, {})
return Object.entries(grouped).map(([group, items]) => `
<li data-slot="group">
<h6 data-group-label>${group}</h6>
<ul data-slot="items">
${items.map(result => this.#renderItem(result, query)).join("")}
</ul>
</li>
`).join("")
}
#renderItem(result, query) {
return `
<li data-slot="item">
<a href="${result.href}" data-result-link>
<h5 data-result-title>${highlight(result.title, query)}</h5>
<p data-result-excerpt>${highlight(excerpt(result.body, query), query)}</p>
</a>
</li>
`
}
#updateSelection(results) {
results.forEach((result, index) => {
const listItem = result.closest("li")
if (index === this.selectedIndex) {
listItem.setAttribute("data-selected", "")
listItem.scrollIntoView({ block: "nearest" })
} else {
listItem.removeAttribute("data-selected")
}
})
}
get #config() {
const defaults = {
fields: ["title", "headings", "body"],
idField: "slug",
storeFields: ["title", "body", "href", "slug"],
searchOptions: {
prefix: true,
boost: { title: 30, headings: 20 },
combineWith: "and",
fuzzy: true
}
}
const customConfig = this.getAttribute("data-config")
if (!customConfig) return defaults
const parsed = JSON.parse(customConfig)
return {
...defaults,
...parsed,
fields: parsed.fields || defaults.fields,
storeFields: [...new Set([...defaults.storeFields, ...(parsed.storeFields || [])])],
searchOptions: {
...defaults.searchOptions,
...parsed.searchOptions,
boost: {
...defaults.searchOptions.boost,
...parsed.searchOptions?.boost
}
}
}
}
get #input() {
return this.querySelector("input[type='search']")
}
get #submitButton() {
return this.querySelector("button[type='submit']")
}
get #resultsContainer() {
return this.querySelector("[data-slot='results']")
}
get #searchEndpoint() {
return this.getAttribute("data-endpoint")
}
get #emptyMessage() {
return this.getAttribute("data-empty") || "No results found"
}
}
customElements.define("search-form", SearchForm)
TEXT
create_file "app/javascript/helpers/search.js", <<~'TEXT', force: true
export function excerpt(text, query, maxLength = 150) {
const terms = query.trim().split(/\s+/).filter(Boolean)
if (!terms.length) return truncate(text, maxLength)
const pattern = terms.map(escapeRegex).join("|")
const match = text.match(new RegExp(pattern, "i"))
if (!match) return truncate(text, maxLength)
const halfWindow = maxLength / 2
const start = Math.max(0, match.index - halfWindow)
const end = Math.min(text.length, start + maxLength)
const snippet = text.slice(start, end)
return `${start > 0 ? "…" : ""}${snippet}${end < text.length ? "…" : ""}`
}
export function highlight(text, query) {
const terms = query.trim().split(/\s+/).filter(Boolean)
if (!terms.length) return text
const pattern = terms.map(escapeRegex).join("|")
return text.replace(new RegExp(`(${pattern})`, "gi"), "<mark>$1</mark>")
}
function truncate(text, length) {
return text.length > length ? `${text.slice(0, length)}…` : text
}
function escapeRegex(value) {
return value.replace(/[.*+?^${}()|[\]]\]/g, "\\$&")
}
TEXT
end
This web component provides instant full-text search powered by MiniSearch. It features fuzzy matching, field boosting, grouped results and keyboard navigation. This template configures most for you, but there are a few manual steps to go through.
Features
- Instant search with 150ms debounce
- Fuzzy matching handles typos automatically
- Prefix search finds “doc” when typing “documentation”
- Keyboard navigation with Arrow keys, Enter, and Escape
-
ARIA attributes for accessibility (
aria-busy) - Grouped results by collection, category or any field
- Highlighted matches in titles and excerpts
Configure which collections are indexed in your initializer:
# config/initializers/perron.rb
Perron.configure do |config|
config.search_scope = %w[posts pages]
end
This includes all posts and pages in the search index.
Add fields to search per content type using search_fields:
class Content::Post < Perron::Resource
search_fields :description, :collection_name
end
These fields are added to the default search fields: title, headings, and body.
Usage
<search-form data-endpoint="<%= search_path(trailing_slash: false) %>">
<input type="search" placeholder="Search…" />
<ul data-slot="results"></ul>
</search-form>
With empty state
<search-form
data-endpoint="<%= search_path(trailing_slash: false) %>"
data-empty="No results"
>
<input type="search" placeholder="Search…" />
<ul data-slot="results"></ul>
<div data-slot="empty"></div>
</search-form>
With grouped results (you can group by any defined key):
<search-form
data-endpoint="<%= search_path(trailing_slash: false) %>"
data-group-by="collection_name"
>
<input type="search" placeholder="Search…" />
<ul data-slot="results"></ul>
</search-form>
With keyboard shortcut
<search-form
data-endpoint="<%= search_path(trailing_slash: false) %>"
data-shortcut="$mod+k"
>
<input type="search" placeholder="Search…" />
<ul data-slot="results"></ul>
</search-form>
Configuration
Attributes
| Attribute | Description |
|---|---|
data-endpoint |
URL to the search index JSON endpoint |
data-config |
JSON string with MiniSearch configuration |
data-empty |
Message to display when no results found |
data-group-by |
Field name to group results by |
data-shortcut |
Keyboard shortcut to focus the input |
Data Config
Pass a JSON configuration object to customize search behavior:
<search-form
data-endpoint="<%= search_path(trailing_slash: false) %>"
data-config='{
"fields": ["title", "headings", "body", "description"],
"storeFields": ["title", "body", "href", "slug", "category"],
"searchOptions": {
"boost": {"title": 30, "category": 25, "description": 20},
"prefix": true,
"fuzzy": 0.2
}
}'
>
Fields (required)
Array of field names to search. Each field gets weighted equally by default.
Store Fields (required)
Array of field names to include in search results for rendering. Must include href for links to work.
Search Options
-
boost- Increase weight for specific fields (higher = more relevant) -
prefix- Match partial words at the start (default: true) -
fuzzy- Allow typos (0-1, where 1 is most lenient; default: 0.2) -
combineWith- How to combine terms ("AND"or"OR"; default:"AND")
Styling
The component uses semantic data attributes for styling:
| Selector | Description |
|---|---|
[data-slot="results"] |
Results container |
[data-slot="empty"] |
Empty state container |
[data-group-label] |
Group header text |
[data-result-link] |
Individual result link |
[data-result-title] |
Result title |
[data-result-excerpt] |
Result excerpt |
[data-selected] |
Currently selected item |
State Attributes
| Attribute | When Set |
|---|---|
data-open |
Results dropdown is visible |
data-focused |
Input has focus |
data-results |
Results are displayed |
data-empty |
No results found |
data-busy |
Loading search index |
Example CSS
search-form {
position: relative;
[data-slot="results"],
[data-slot="empty"] {
position: absolute;
top: 100%;
width: 100%;
background: white;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
}
[data-slot="results"] {
display: grid;
gap: 0.5rem;
max-height: 24rem;
overflow-y: auto;
&:empty { display: none; }
}
[data-result-link] {
display: block;
padding: 0.5rem;
text-decoration: none;
&:hover,
[data-selected] & {
background: #f1f5f9;
}
}
[data-result-title] {
font-weight: 500;
color: #0f172a;
}
[data-result-excerpt] {
font-size: 0.875rem;
color: #64748b;
}
mark {
background: linear-gradient(to bottom right, #fecaca, #fed7aa);
border-radius: 0.125rem 0.5rem 0.5rem 0.125rem;
}
}