Filter Items
to run in your site's root directory
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
create_file "app/javascript/components/index.js", <<~JS, skip: true
import "components/filter_items"
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
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/application.js", <<~'TEXT', force: true
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "components"
TEXT
create_file "app/javascript/components/filter_items.js", <<~'TEXT', force: true
export default class FilterItems extends HTMLElement {
#form = null
#items = []
#index = new Map()
#unsubscribe = null
static get observedAttributes() {
return ["form", "mode"]
}
connectedCallback() {
this.#initialize()
}
disconnectedCallback() {
if (this.#unsubscribe) this.#unsubscribe()
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) return
if (name === "form") this.#initialize()
}
// private
#initialize() {
const formId = this.getAttribute("form")
if (!formId) return
this.#form = document.getElementById(formId)
if (!this.#form) return
this.#items = this.#allItems()
this.#buildIndex()
this.#observe()
this.#filter()
}
#allItems() {
return Array.from(this.children).filter((child) => {
const hasFilterableAttributes = Array.from(child.attributes).some(
(attribute) => {
return (
attribute.name.startsWith("data-") &&
attribute.name !== "data-filter-exclude" &&
attribute.name !== "data-empty-state"
)
}
)
return hasFilterableAttributes && !child.hasAttribute("data-empty-state")
})
}
#buildIndex() {
this.#index.clear()
this.#attributeNames().forEach((name) => {
const valueIndex = new Map()
this.#items.forEach((item, index) => {
const attributeValue = item.getAttribute(`data-${name}`) || ""
const values = attributeValue
.split(",")
.map((value) => value.trim())
.filter(Boolean)
values.forEach((value) => {
if (!valueIndex.has(value)) valueIndex.set(value, [])
valueIndex.get(value).push(index)
})
})
this.#index.set(name, valueIndex)
})
}
#observe() {
this.#unsubscribe = () => {}
const handleChange = () => this.#filter()
const inputs = this.#form.querySelectorAll("[name]")
inputs.forEach((input) => {
input.addEventListener("change", handleChange)
input.addEventListener("input", handleChange)
})
this.#unsubscribe = () => {
inputs.forEach((input) => {
input.removeEventListener("change", handleChange)
input.removeEventListener("input", handleChange)
})
}
}
#filter() {
if (!this.#form) return
const mode = this.getAttribute("mode") || "any"
const activeInputs = this.#activeInputs
const counts = {}
this.#index.forEach((value, name) => {
counts[name] = { total: 0, filtered: 0 }
})
this.#items.forEach((item) => {
if (item.hasAttribute("data-filter-exclude")) {
item.removeAttribute("hidden")
return
}
const visible = this.#itemMatchesActiveInputs(item, activeInputs, mode)
this.#index.forEach((value, name) => {
const attributeValue = item.getAttribute(`data-${name}`) || ""
if (attributeValue) counts[name].total++
if (visible && attributeValue) counts[name].filtered++
})
if (visible) {
item.removeAttribute("hidden")
} else {
item.setAttribute("hidden", "")
}
})
this.#updateCounts(counts)
this.#updateEmptyState()
this.#dispatchFilterChangeEvent()
}
#attributeNames() {
const names = new Set()
this.#items.forEach((item) => {
for (const attribute of item.attributes) {
if (
attribute.name.startsWith("data-") &&
attribute.name !== "data-filter-exclude"
) {
names.add(attribute.name.slice(5))
}
}
})
return names
}
#itemMatchesActiveInputs(item, activeInputs, mode) {
if (activeInputs.length === 0) return true
const matches = activeInputs.map((input) => {
const itemValue = item.getAttribute(`data-${input.name}`) || ""
const values = itemValue.split(",").map((value) => value.trim())
return values.includes(input.value)
})
if (mode === "all") {
return matches.every(Boolean)
} else {
return matches.some(Boolean)
}
}
#updateCounts(counts) {
Object.entries(counts).forEach(([name, count]) => {
this.#form.setAttribute(`${name}-total-count`, count.total)
this.#form.setAttribute(`${name}-filtered-count`, count.filtered)
})
}
#updateEmptyState() {
const activeInputs = this.#activeInputs
const visibleItems = this.#items.filter(
(item) =>
!item.hasAttribute("data-filter-exclude") &&
!item.hasAttribute("hidden")
)
const emptyState = this.querySelector("[data-empty-state]")
if (emptyState) {
if (activeInputs.length > 0 && visibleItems.length === 0) {
emptyState.removeAttribute("hidden")
} else {
emptyState.setAttribute("hidden", "")
}
}
}
#dispatchFilterChangeEvent() {
this.dispatchEvent(
new CustomEvent("filterchange", {
bubbles: true,
detail: {
form: this.#form,
visibleCount: this.#items.filter(
(item) => !item.hasAttribute("data-filter-exclude") && !item.hasAttribute("hidden")
).length,
totalCount: this.#items.filter(
(item) => !item.hasAttribute("data-filter-exclude")
).length
}
})
)
}
get #activeInputs() {
const inputs = this.#form.querySelectorAll("[name]")
const active = []
inputs.forEach((input) => {
let value
if (input.type === "checkbox" || input.type === "radio") {
if (!input.checked) return
value = input.value
} else {
value = input.value.trim()
if (!value) return
}
active.push({ name: input.name, value })
})
return active
}
}
customElements.define("filter-items", FilterItems)
TEXT
end
This web component filters a list of items based on form inputs (checkboxes or radio buttons). Items are shown or hidden by matching their data-* attributes against the selected form values.
Features
- Multi-value filtering - Items can match multiple categories (comma-separated)
- Match modes - Filter by “any” (OR) or “all” (AND) selected values
- Exclusion - Mark items to exclude from filtering entirely
- Empty state - Display a message when no items match
- Count tracking - Form attributes track total and filtered counts
-
Event API - Listen to
filterchangeevents for custom behavior
Usage
Basic usage with checkboxes:
<form id="filters">
<label><input type="checkbox" name="category" value="code"> Code</label>
<label><input type="checkbox" name="category" value="product"> Product</label>
</form>
<filter-items form="filters">
<span data-category="code">Gem install guide</span>
<span data-category="product">Pro plan</span>
</filter-items>
With empty state:
<filter-items form="filters">
<span data-category="code">Gem install guide</span>
<span data-category="product">Pro plan</span>
<p data-empty-state hidden>No items match your filters</p>
</filter-items>
Multi-category items (comma-separated values):
<filter-items form="filters">
<span data-category="code,tutorial">Rails tips</span>
<span data-category="product,design">Pro resources</span>
</filter-items>
Attributes
| Attribute | Description |
|---|---|
form |
ID of the form containing filter inputs |
mode |
Match mode: "any" (OR) or "all" (AND) |
Data Attributes on Items
Items use data-* attributes where * matches the name attribute on form inputs:
<span data-category="code,tutorial">Rails tips</span>
| Attribute | Purpose |
|---|---|
data-{name} |
Values to filter by (comma-separated) |
data-empty-state |
Marks the empty state element |
data-filter-exclude |
Excludes item from filtering |
Form Count Attributes
The form element receives count attributes for each filter type:
<form id="filters" category-total-count="10" category-filtered-count="3">
Events
Listen to filterchange for custom behavior:
document.querySelector("filter-items").addEventListener("filterchange", (event) => {
console.log("Visible:", event.detail.visibleCount)
console.log("Total:", event.detail.totalCount)
})