fix(admin): live search not firing on text input + spinner always visible

hx-trigger bug:
  "from:find input" in hx-trigger attaches the event listener to the
  first <input> found in the form — which is the hidden CSRF token input.
  Typing in the visible search field never fires the listener on that
  element. Result: only Enter (form submit) triggered HTMX.
  Fix: drop "from:find input" so the listener is on the form itself,
  where input/change events from all children bubble naturally.

Spinner visibility bug:
  .search-spinner { opacity: 0 } relied on our compiled output.css.
  HTMX ships its own built-in CSS for .htmx-indicator (opacity:0 →
  opacity:1 on htmx-request). Using class="htmx-indicator search-spinner"
  delegates hide/show to HTMX's own stylesheet with no dependency on
  whether output.css has been rebuilt. Our .search-spinner only handles
  positioning and the spin animation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-24 17:09:49 +01:00
parent 6d18c52983
commit 165eaf48bf
7 changed files with 15 additions and 15 deletions

View File

@@ -28,7 +28,7 @@
<form class="flex flex-wrap gap-3 items-end" <form class="flex flex-wrap gap-3 items-end"
hx-get="{{ url_for('admin.article_results') }}" hx-get="{{ url_for('admin.article_results') }}"
hx-target="#article-results" hx-target="#article-results"
hx-trigger="change, input delay:300ms from:find input" hx-trigger="change, input delay:300ms"
hx-indicator="#articles-loading"> hx-indicator="#articles-loading">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
@@ -67,7 +67,7 @@
</select> </select>
</div> </div>
<svg id="articles-loading" class="search-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true"> <svg id="articles-loading" class="htmx-indicator search-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="10" stroke="#CBD5E1" stroke-width="3"/> <circle cx="12" cy="12" r="10" stroke="#CBD5E1" stroke-width="3"/>
<path d="M12 2a10 10 0 0 1 10 10" stroke="#0EA5E9" stroke-width="3" stroke-linecap="round"/> <path d="M12 2a10 10 0 0 1 10 10" stroke="#0EA5E9" stroke-width="3" stroke-linecap="round"/>
</svg> </svg>

View File

@@ -24,7 +24,7 @@
<form class="flex flex-wrap gap-3 items-end" <form class="flex flex-wrap gap-3 items-end"
hx-get="{{ url_for('admin.email_results') }}" hx-get="{{ url_for('admin.email_results') }}"
hx-target="#email-results" hx-target="#email-results"
hx-trigger="change, input delay:300ms from:find input" hx-trigger="change, input delay:300ms"
hx-indicator="#emails-loading"> hx-indicator="#emails-loading">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
@@ -53,7 +53,7 @@
<input type="text" name="search" value="{{ current_search }}" class="form-input" placeholder="Email or subject..." style="min-width:180px"> <input type="text" name="search" value="{{ current_search }}" class="form-input" placeholder="Email or subject..." style="min-width:180px">
</div> </div>
<svg id="emails-loading" class="search-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true"> <svg id="emails-loading" class="htmx-indicator search-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="10" stroke="#CBD5E1" stroke-width="3"/> <circle cx="12" cy="12" r="10" stroke="#CBD5E1" stroke-width="3"/>
<path d="M12 2a10 10 0 0 1 10 10" stroke="#0EA5E9" stroke-width="3" stroke-linecap="round"/> <path d="M12 2a10 10 0 0 1 10 10" stroke="#0EA5E9" stroke-width="3" stroke-linecap="round"/>
</svg> </svg>

View File

@@ -25,7 +25,7 @@
<form class="flex flex-wrap gap-3 items-end" <form class="flex flex-wrap gap-3 items-end"
hx-get="{{ url_for('admin.lead_results') }}" hx-get="{{ url_for('admin.lead_results') }}"
hx-target="#lead-results" hx-target="#lead-results"
hx-trigger="change, input delay:300ms from:find input" hx-trigger="change, input delay:300ms"
hx-indicator="#leads-loading"> hx-indicator="#leads-loading">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
@@ -59,7 +59,7 @@
</select> </select>
</div> </div>
<svg id="leads-loading" class="search-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true"> <svg id="leads-loading" class="htmx-indicator search-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="10" stroke="#CBD5E1" stroke-width="3"/> <circle cx="12" cy="12" r="10" stroke="#CBD5E1" stroke-width="3"/>
<path d="M12 2a10 10 0 0 1 10 10" stroke="#0EA5E9" stroke-width="3" stroke-linecap="round"/> <path d="M12 2a10 10 0 0 1 10 10" stroke="#0EA5E9" stroke-width="3" stroke-linecap="round"/>
</svg> </svg>

View File

@@ -20,7 +20,7 @@
<form class="card mb-4 flex flex-wrap gap-3 items-end" <form class="card mb-4 flex flex-wrap gap-3 items-end"
hx-get="{{ url_for('admin.scenario_results') }}" hx-get="{{ url_for('admin.scenario_results') }}"
hx-target="#scenario-results" hx-target="#scenario-results"
hx-trigger="change, input delay:300ms from:find input" hx-trigger="change, input delay:300ms"
hx-indicator="#scenario-loading"> hx-indicator="#scenario-loading">
<div class="flex-1 min-w-48"> <div class="flex-1 min-w-48">
@@ -53,7 +53,7 @@
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<button type="button" class="btn-outline" <button type="button" class="btn-outline"
onclick="this.closest('form').reset(); htmx.trigger(this.closest('form'), 'change')">Clear</button> onclick="this.closest('form').reset(); htmx.trigger(this.closest('form'), 'change')">Clear</button>
<svg id="scenario-loading" class="search-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true"> <svg id="scenario-loading" class="htmx-indicator search-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="10" stroke="#CBD5E1" stroke-width="3"/> <circle cx="12" cy="12" r="10" stroke="#CBD5E1" stroke-width="3"/>
<path d="M12 2a10 10 0 0 1 10 10" stroke="#0EA5E9" stroke-width="3" stroke-linecap="round"/> <path d="M12 2a10 10 0 0 1 10 10" stroke="#0EA5E9" stroke-width="3" stroke-linecap="round"/>
</svg> </svg>

View File

@@ -24,7 +24,7 @@
<form class="flex flex-wrap gap-3 items-end" <form class="flex flex-wrap gap-3 items-end"
hx-get="{{ url_for('admin.supplier_results') }}" hx-get="{{ url_for('admin.supplier_results') }}"
hx-target="#supplier-results" hx-target="#supplier-results"
hx-trigger="change, input delay:300ms from:find input" hx-trigger="change, input delay:300ms"
hx-indicator="#suppliers-loading"> hx-indicator="#suppliers-loading">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
@@ -54,7 +54,7 @@
</select> </select>
</div> </div>
<svg id="suppliers-loading" class="search-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true"> <svg id="suppliers-loading" class="htmx-indicator search-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="10" stroke="#CBD5E1" stroke-width="3"/> <circle cx="12" cy="12" r="10" stroke="#CBD5E1" stroke-width="3"/>
<path d="M12 2a10 10 0 0 1 10 10" stroke="#0EA5E9" stroke-width="3" stroke-linecap="round"/> <path d="M12 2a10 10 0 0 1 10 10" stroke="#0EA5E9" stroke-width="3" stroke-linecap="round"/>
</svg> </svg>

View File

@@ -13,13 +13,13 @@
<form class="flex gap-3 items-center" <form class="flex gap-3 items-center"
hx-get="{{ url_for('admin.user_results') }}" hx-get="{{ url_for('admin.user_results') }}"
hx-target="#user-results" hx-target="#user-results"
hx-trigger="input delay:300ms from:find input" hx-trigger="input delay:300ms"
hx-indicator="#user-loading"> hx-indicator="#user-loading">
<div class="flex-1 max-w-sm"> <div class="flex-1 max-w-sm">
<input type="search" name="search" class="form-input w-full" <input type="search" name="search" class="form-input w-full"
placeholder="Search by email…" value="{{ search }}"> placeholder="Search by email…" value="{{ search }}">
</div> </div>
<svg id="user-loading" class="search-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true"> <svg id="user-loading" class="htmx-indicator search-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="10" stroke="#CBD5E1" stroke-width="3"/> <circle cx="12" cy="12" r="10" stroke="#CBD5E1" stroke-width="3"/>
<path d="M12 2a10 10 0 0 1 10 10" stroke="#0EA5E9" stroke-width="3" stroke-linecap="round"/> <path d="M12 2a10 10 0 0 1 10 10" stroke="#0EA5E9" stroke-width="3" stroke-linecap="round"/>
</svg> </svg>

View File

@@ -569,14 +569,14 @@
@apply px-4 pb-4 text-slate-dark; @apply px-4 pb-4 text-slate-dark;
} }
/* Inline HTMX loading indicator for search forms */ /* Inline HTMX loading indicator for search forms.
Opacity is handled by HTMX's built-in .htmx-indicator CSS.
This class only adds positioning and the spin animation. */
.search-spinner { .search-spinner {
opacity: 0;
flex-shrink: 0; flex-shrink: 0;
align-self: center; align-self: center;
} }
.search-spinner.htmx-request { .search-spinner.htmx-request {
opacity: 1;
animation: spin-icon 0.9s linear infinite; animation: spin-icon 0.9s linear infinite;
} }