Searchable combobox
Debounced live filtering over an in-memory list — zero custom JavaScript.
# Mount the component anywhere a Phlex view renders:
render SearchableComboboxComponent.new
# Pre-seed the search or selection (both ride in the signed token):
render SearchableComboboxComponent.new(query: "ru", selected_name: "Ruby")
# frozen_string_literal: true
# The docs site's headline reactive demo (issue #51): a searchable combobox with
# ZERO custom JavaScript. It is STATE-backed — `query` and `selected_name` ride
# in the signed identity token, never as client-trusted state. A debounced
# `search` action filters a frozen in-memory list (no DB, no PgSearch); a
# `select` action reports the chosen item back.
#
# The whole interaction is one re-render unit: each action re-renders THIS
# component into its own `#id` via `reply.morph`, which preserves the focused
# input + caret (the value the morph renders matches what the user typed) and
# rolls the signed token forward in the same stream.
class SearchableComboboxComponent < Phlex::HTML
include Phlex::Reactive::Streamable
include Phlex::Reactive::Component
# A frozen, in-memory catalogue. It is the SOURCE the server filters on every
# action — it is never shipped to or trusted from the client. `select` checks
# membership against it so a forged name can't become the selection.
LANGUAGES = [
{ name: 'Ruby', paradigm: 'Object-oriented' },
{ name: 'Smalltalk', paradigm: 'Object-oriented' },
{ name: 'Python', paradigm: 'Object-oriented' },
{ name: 'Elixir', paradigm: 'Functional' },
{ name: 'Erlang', paradigm: 'Functional' },
{ name: 'Haskell', paradigm: 'Functional' },
{ name: 'Clojure', paradigm: 'Functional' },
{ name: 'OCaml', paradigm: 'Functional' },
{ name: 'Rust', paradigm: 'Systems' },
{ name: 'C', paradigm: 'Systems' },
{ name: 'C++', paradigm: 'Systems' },
{ name: 'Zig', paradigm: 'Systems' },
{ name: 'Go', paradigm: 'Systems' },
{ name: 'TypeScript', paradigm: 'Scripting' },
{ name: 'JavaScript', paradigm: 'Scripting' },
{ name: 'Lua', paradigm: 'Scripting' }
].freeze
# Only these two values are signed into the token.
reactive_state :query, :selected_name
# Both actions declare a schema — only `query` / `name` reach the method, each
# coerced to a String. An undeclared action is denied (403).
action :search, params: { query: :string }
action :select, params: { name: :string }
def initialize(query: '', selected_name: nil)
@query = query.to_s
@selected_name = selected_name
end
# Render-context-free (runs before render). Stable so the component self-targets.
def id = 'language-combobox'
# Debounced live filter. `reply.morph` re-renders this root in place, so the
# focused <input> and its caret survive (Idiomorph keeps the node whose value
# already matches @query) and the token refreshes — no `reply.replace` blur.
def search(query:)
@query = query.to_s
reply.morph
end
# Report a selection back. `name` is client-supplied, so it MUST be validated
# against the real list before becoming state (NO trusting client input). A
# real mutating select would also `authorize!` here and register its error in
# Phlex::Reactive.authorization_errors.
def select(name:)
return reply.morph unless LANGUAGES.any? { it[:name] == name }
@selected_name = name
@query = name
reply.morph
end
def view_template
# reactive_root co-locates #id + the controller + the signed token on ONE
# element (issue #48): never put id: on a child or token threading breaks.
div(**reactive_root(class: 'flex flex-col gap-3')) do
# The input + the floating results share a relative wrapper so the popup
# anchors under the field (like a real combobox dropdown).
div(class: 'relative') do
search_field
results if show_results?
end
selection_chip if selected?
end
end
private
def selected? = @selected_name.present?
# The dropdown pops up only while the user is actively typing a search — not
# when the field is empty, and not after a selection (when @query == the
# chosen name). Re-rendered on every keystroke via the debounced morph, so the
# panel appears/disappears as the query changes.
def show_results?
q = @query.to_s.strip
return false if q.empty?
q != @selected_name.to_s
end
# The list filtered by the current query, grouped by paradigm. Re-derived every
# action from @query — it is NOT reactive_state (the list isn't client state).
def filtered
needle = @query.to_s.strip.downcase
rows = needle.empty? ? LANGUAGES : LANGUAGES.select { it[:name].downcase.include?(needle) }
rows.group_by { it[:paradigm] }
end
def search_field
# The DEBOUNCED search trigger lives on the INPUT (input event). Its value
# travels as the `query` param because it's a named control of this root.
# mix() deep-merges so on()'s data-action is never clobbered (a bare data:
# would silently unbind the action -> 403 on the next keystroke).
input(**mix(
on(:search, event: 'input', debounce: 200),
type: 'text',
name: 'query',
value: @query,
placeholder: 'Search languages…',
autocomplete: 'off',
class: 'input input-bordered w-full',
data: { testid: 'combobox-query' }
))
end
def selection_chip
div(class: 'badge badge-primary gap-2', data: { testid: 'combobox-selection' }) do
plain 'Selected: '
span { @selected_name }
end
end
# The floating dropdown panel, absolutely positioned under the input. Only
# rendered when show_results? — so it pops up while typing and collapses
# otherwise.
def results
div(class: 'absolute left-0 right-0 top-full z-20 mt-1') do
groups = filtered
if groups.empty?
div(class: 'bg-base-200 rounded-box border border-base-300 px-3 py-2 text-sm opacity-60 shadow-lg',
data: { testid: 'combobox-empty' }) { 'No matches' }
return
end
ul(class: 'menu bg-base-200 rounded-box border border-base-300 w-full ' \
'max-h-64 flex-nowrap overflow-y-auto shadow-lg',
data: { testid: 'combobox-results' }) do
groups.each do |paradigm, langs|
li(class: 'menu-title') { paradigm }
langs.each { option_row(it) }
end
end
end
end
def option_row(lang)
li do
# The SELECT trigger lives on the BUTTON (click), not the input — so typing
# in the field never fires select. The explicit `name:` param wins over any
# collected field. type="button" (forced by on for click) keeps a bare
# button from submitting an enclosing form.
button(**mix(
on(:select, name: lang[:name]),
class: 'justify-start',
data: { testid: "combobox-option-#{lang[:name].parameterize}" }
)) { lang[:name] }
end
end
end