feat(badges): search filter polish and result limit stepper

- Replace show_hidden checkbox with visibility_filter select (Default /
  Show Hidden / Show Disabled+Hidden) — collapses two orphaned boolean
  fields (show_hidden, show_not_enabled) into one purpose-built value;
  wires disabled-badge filter through to both IDB and API paths
- Add max-results stepper (edit mode only): steps of 25 up to 250,
  steps of 100 up to 2550; tier-capped (trusted=250, manager=2550);
  stepper uses pure reactivity — no handle_search_trigger() call needed
- Fix fallback liveQuery (SCENARIO 2): was hardcoded .limit(50);
  now reads qry_result_limit in outer $derived.by so Svelte tracks it
  and stepper updates the no-text browse list immediately
- Fix Search button disabled state: replace pointer-events-none +
  class:opacity-50 with HTML disabled attribute + disabled:cursor-not-allowed
  so hover cursor reflects disabled state correctly
- Global placeholder fix (app.css): add italic + opacity-0.6 rule for
  .input/.textarea ::placeholder in light mode; add italic to dark rule —
  prevents placeholder text from reading as typed content

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-06-04 18:33:59 -04:00
parent b45a27481a
commit 9d904446d4
5 changed files with 110 additions and 32 deletions

View File

@@ -163,9 +163,16 @@ html.light {
background-color: rgb(55 65 81); /* gray-700 */ background-color: rgb(55 65 81); /* gray-700 */
border-color: rgb(75 85 99); /* gray-600 */ border-color: rgb(75 85 99); /* gray-600 */
} }
.input::placeholder,
.textarea::placeholder {
font-style: italic;
opacity: 0.6;
}
.dark .input::placeholder, .dark .input::placeholder,
.dark .textarea::placeholder { .dark .textarea::placeholder {
color: rgb(156 163 175); /* gray-400 — legible at reduced opacity */ color: rgb(156 163 175); /* gray-400 */
font-style: italic;
opacity: 0.8; /* gray-400 is already dim; subtle additional fade */
} }
/* Option elements in dark selects — forces browser native dark chrome */ /* Option elements in dark selects — forces browser native dark chrome */
.dark .select option { .dark .select option {

View File

@@ -97,8 +97,8 @@ export const default_trusted_can_edit: string[] = [
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export interface BadgesLocState { export interface BadgesLocState {
auto_view: boolean; auto_view: boolean;
show_hidden: boolean; // 'default' = hide hidden+disabled | 'show_hidden' = show hidden | 'show_all' = show hidden+disabled
show_not_enabled: boolean; visibility_filter: 'default' | 'show_hidden' | 'show_all';
show_printed: boolean; show_printed: boolean;
allow_reprint: boolean; allow_reprint: boolean;
show_element__cfg: boolean; show_element__cfg: boolean;
@@ -110,6 +110,7 @@ export interface BadgesLocState {
qry_printed_status: string; // 'all' | 'printed' | 'not_printed' qry_printed_status: string; // 'all' | 'printed' | 'not_printed'
qry_affiliations: string | null; qry_affiliations: string | null;
qry_sort_order: string; qry_sort_order: string;
qry_result_limit: number; // UI override for max results (edit mode only; tier max enforced by stepper)
status_qry__search: string | null; status_qry__search: string | null;
use_id_li: boolean; use_id_li: boolean;
search_status: string | null; search_status: string | null;
@@ -158,8 +159,7 @@ export interface BadgesSessState {
export const badges_loc_defaults: BadgesLocState = { export const badges_loc_defaults: BadgesLocState = {
auto_view: true, auto_view: true,
show_hidden: false, // Hidden (archived) badges are excluded from the main list. visibility_filter: 'default', // 'default' | 'show_hidden' | 'show_all'
show_not_enabled: false,
show_printed: false, show_printed: false,
allow_reprint: false, allow_reprint: false,
@@ -178,6 +178,7 @@ export const badges_loc_defaults: BadgesLocState = {
qry_printed_status: 'all', // 'all' | 'printed' | 'not_printed' qry_printed_status: 'all', // 'all' | 'printed' | 'not_printed'
qry_affiliations: null, // null = no affiliation filter qry_affiliations: null, // null = no affiliation filter
qry_sort_order: '', // '' = default sort order qry_sort_order: '', // '' = default sort order
qry_result_limit: 25,
status_qry__search: null, status_qry__search: null,
use_id_li: true, use_id_li: true,

View File

@@ -101,6 +101,9 @@ $effect(() => {
let lq__event_badge_obj_li = $derived.by(() => { let lq__event_badge_obj_li = $derived.by(() => {
const ids = event_badge_id_li; const ids = event_badge_id_li;
const event_id = $events_slct?.event_id; const event_id = $events_slct?.event_id;
// Read in outer scope so Svelte tracks it — liveQuery async callbacks are not tracked.
// In edit mode the stepper controls the limit; otherwise fall back to 50.
const fallback_limit = $ae_loc.edit_mode ? badges_loc.current.qry_result_limit : 50;
return liveQuery(async () => { return liveQuery(async () => {
// SCENARIO 1: Specific IDs provided (Search Results) // SCENARIO 1: Specific IDs provided (Search Results)
@@ -128,7 +131,7 @@ let lq__event_badge_obj_li = $derived.by(() => {
return await db_events.badge return await db_events.badge
.where('event_id') .where('event_id')
.equals(event_id) .equals(event_id)
.limit(50) .limit(fallback_limit)
.sortBy('given_name'); .sortBy('given_name');
} }
@@ -169,10 +172,13 @@ let search_params = $derived({
printed: badges_loc.current.qry_printed_status, printed: badges_loc.current.qry_printed_status,
aff: (badges_loc.current.qry_affiliations ?? '').toLowerCase().trim(), aff: (badges_loc.current.qry_affiliations ?? '').toLowerCase().trim(),
sort: badges_loc.current.qry_sort_order, sort: badges_loc.current.qry_sort_order,
show_hidden: badges_loc.current.show_hidden, visibility_filter: badges_loc.current.visibility_filter,
event_id: $events_slct?.event_id, event_id: $events_slct?.event_id,
remote_first: badges_loc.current.qry__remote_first, remote_first: badges_loc.current.qry__remote_first,
result_limit: effective_search_limits.result_limit, // In edit mode the stepper overrides the server-configured tier limit.
result_limit: $ae_loc.edit_mode
? badges_loc.current.qry_result_limit
: effective_search_limits.result_limit,
min_chars: effective_search_limits.min_chars min_chars: effective_search_limits.min_chars
}); });
@@ -219,7 +225,9 @@ async function handle_search_refresh(params: any) {
const result_limit = params.result_limit; const result_limit = params.result_limit;
const min_chars = params.min_chars; const min_chars = params.min_chars;
const show_hidden = params.show_hidden; const visibility_filter = params.visibility_filter as 'default' | 'show_hidden' | 'show_all';
const show_hidden = visibility_filter !== 'default';
const show_all = visibility_filter === 'show_all';
// Defense-in-depth: enforce min_chars even if the search component lets one through. // Defense-in-depth: enforce min_chars even if the search component lets one through.
// Exception: if the user has set a non-default filter or sort, that is explicit intent — // Exception: if the user has set a non-default filter or sort, that is explicit intent —
@@ -243,8 +251,9 @@ async function handle_search_refresh(params: any) {
.where('event_id') .where('event_id')
.equals(event_id) .equals(event_id)
.filter((badge) => { .filter((badge) => {
// Exclude hidden badges unless show_hidden is active // Exclude hidden/disabled badges unless the visibility filter allows them
if (!show_hidden && badge.hide) return false; if (!show_hidden && badge.hide) return false;
if (!show_all && badge.enable === false) return false;
if (type_code && badge.badge_type_code !== type_code) if (type_code && badge.badge_type_code !== type_code)
return false; return false;
@@ -423,6 +432,7 @@ async function handle_search_refresh(params: any) {
type_code: type_code || null, type_code: type_code || null,
printed_status: printed_status, printed_status: printed_status,
affiliations_qry_str: aff_str || null, affiliations_qry_str: aff_str || null,
enabled: show_all ? 'all' : 'enabled',
hidden: show_hidden ? 'all' : 'not_hidden', hidden: show_hidden ? 'all' : 'not_hidden',
order_by_li: order_by_li, order_by_li: order_by_li,
limit: result_limit, limit: result_limit,

View File

@@ -101,9 +101,11 @@ let visible_badge_obj_li = $derived(
if (list === undefined || list === null) return null; if (list === undefined || list === null) return null;
if (!Array.isArray(list)) return []; if (!Array.isArray(list)) return [];
// show_hidden requires trusted + edit_mode — it's an admin override, not a const vf = badges_loc.current.visibility_filter;
// visibility_filter requires trusted + edit_mode — it's an admin override, not a
// persistent search filter. Turning off edit mode reverts to hiding hidden badges. // persistent search filter. Turning off edit mode reverts to hiding hidden badges.
const show_hidden_badges = badges_loc.current.show_hidden && is_trusted && is_edit_mode; const show_hidden_badges = vf !== 'default' && is_trusted && is_edit_mode;
const show_disabled_badges = vf === 'show_all' && is_trusted && is_edit_mode;
const filtered = list.filter((item: any) => { const filtered = list.filter((item: any) => {
if (!item) return false; if (!item) return false;
@@ -112,6 +114,7 @@ let visible_badge_obj_li = $derived(
// Filter state persists across edit mode toggles — intentional. Staff set // Filter state persists across edit mode toggles — intentional. Staff set
// their filter and it stays regardless of whether edit mode is on or off. // their filter and it stays regardless of whether edit mode is on or off.
const hide_ok = show_hidden_badges || !item.hide; const hide_ok = show_hidden_badges || !item.hide;
if (!show_disabled_badges && item.enable === false) return false;
const ps = badges_loc.current.qry_printed_status; const ps = badges_loc.current.qry_printed_status;
if (ps === 'printed') return (item.print_count ?? 0) >= 1 && hide_ok; if (ps === 'printed') return (item.print_count ?? 0) >= 1 && hide_ok;
if (ps === 'not_printed') return (item.print_count ?? 0) < 1 && hide_ok; if (ps === 'not_printed') return (item.print_count ?? 0) < 1 && hide_ok;

View File

@@ -10,12 +10,12 @@ let { event_id, log_lvl = 0 }: Props = $props();
import { import {
// Library, // Library,
LoaderCircle, LoaderCircle,
Minus,
Plus,
QrCode, QrCode,
RemoveFormatting, RemoveFormatting,
Search, Search,
StepForward StepForward
} from '@lucide/svelte'; } from '@lucide/svelte';
import { fade, slide } from 'svelte/transition'; import { fade, slide } from 'svelte/transition';
import { ae_loc, ae_api } from '$lib/stores/ae_stores'; import { ae_loc, ae_api } from '$lib/stores/ae_stores';
@@ -46,6 +46,19 @@ let badge_type_code_li = [
{ code: 'nonmember', name: 'Non-Member' }, { code: 'nonmember', name: 'Non-Member' },
]; ];
// Steps of 25 up to 250, then steps of 100 up to 2500 — tier max enforced by capping the array.
let limit_steps = $derived.by(() => {
const small = Array.from({ length: 10 }, (_, i) => (i + 1) * 25); // 25…250
const large = Array.from({ length: 23 }, (_, i) => 350 + i * 100); // 350…2500
if ($ae_loc.manager_access) return [...small, ...large];
if ($ae_loc.trusted_access) return small;
return [25];
});
let limit_idx = $derived.by(() => {
const idx = limit_steps.indexOf(badges_loc.current.qry_result_limit);
return idx >= 0 ? idx : 0;
});
// Resolve the minimum characters required for the current user's access tier. // Resolve the minimum characters required for the current user's access tier.
// Mirrors the 3-tier logic in +page.svelte so both enforce the same threshold. // Mirrors the 3-tier logic in +page.svelte so both enforce the same threshold.
let effective_min_chars = $derived.by(() => { let effective_min_chars = $derived.by(() => {
@@ -102,6 +115,17 @@ function handle_qr_scan_result(event: {
{#if ($ae_loc.trusted_access && $ae_loc.edit_mode) || $ae_loc.manager_access} {#if ($ae_loc.trusted_access && $ae_loc.edit_mode) || $ae_loc.manager_access}
<div transition:slide={{ duration: 200 }} class="flex flex-row flex-wrap items-center justify-center gap-1"> <div transition:slide={{ duration: 200 }} class="flex flex-row flex-wrap items-center justify-center gap-1">
<span class="flex flex-row flex-wrap items-center justify-center gap-1"> <span class="flex flex-row flex-wrap items-center justify-center gap-1">
<select
bind:value={badges_loc.current.visibility_filter}
onchange={handle_search_trigger}
class="select select-sm max-w-fit px-1 text-xs">
<option value="default">-- Default --</option>
<option value="show_hidden">Show Hidden</option>
{#if $ae_loc.manager_access}
<option value="show_all">Show Disabled + Hidden</option>
{/if}
</select>
<select <select
bind:value={badges_loc.current.search_badge_type_code} bind:value={badges_loc.current.search_badge_type_code}
onchange={handle_search_trigger} onchange={handle_search_trigger}
@@ -145,7 +169,7 @@ function handle_qr_scan_result(event: {
<input <input
type="search" type="search"
placeholder="Affiliations" placeholder="Affiliations/Company"
bind:value={badges_loc.current.qry_affiliations} bind:value={badges_loc.current.qry_affiliations}
onkeyup={(e) => { onkeyup={(e) => {
if ( if (
@@ -234,11 +258,11 @@ function handle_qr_scan_result(event: {
<div class="flex flex-row items-center justify-center gap-1"> <div class="flex flex-row items-center justify-center gap-1">
<button <button
type="submit" type="submit"
class:opacity-50={($events_sess.badges.search_status === 'loading') || (badges_loc.current.fulltext_search_qry_str ?? '').trim().length < effective_min_chars} disabled={($events_sess.badges.search_status === 'loading') || (badges_loc.current.fulltext_search_qry_str ?? '').trim().length < effective_min_chars}
class:pointer-events-none={($events_sess.badges.search_status === 'loading') || (badges_loc.current.fulltext_search_qry_str ?? '').trim().length < effective_min_chars}
class=" class="
hover:text-primary-800-200 hover:bg-primary-200-800 active:bg-surface-200-700 flex items-center justify-center gap-1 px-3 py-2 text-2xl font-bold transition-all duration-1000 hover:duration-50 min-w-0 preset-tonal-success rounded-lg border border-success-200-800 hover:text-primary-800-200 hover:bg-primary-200-800 active:bg-surface-200-700 flex items-center justify-center gap-1 px-3 py-2 text-2xl font-bold transition-all duration-1000 hover:duration-50 min-w-0 preset-tonal-success rounded-lg border border-success-200-800
w-48 w-48
disabled:opacity-50 disabled:cursor-not-allowed
"> ">
{#if $events_sess.badges.search_status === 'loading'} {#if $events_sess.badges.search_status === 'loading'}
<LoaderCircle class="mx-1 animate-spin" /> <LoaderCircle class="mx-1 animate-spin" />
@@ -252,15 +276,18 @@ function handle_qr_scan_result(event: {
type="button" type="button"
class:opacity-0={!badges_loc.current.fulltext_search_qry_str && class:opacity-0={!badges_loc.current.fulltext_search_qry_str &&
!badges_loc.current.search_badge_type_code && !badges_loc.current.search_badge_type_code &&
badges_loc.current.qry_printed_status === 'all'} badges_loc.current.qry_printed_status === 'all' &&
badges_loc.current.visibility_filter === 'default'}
class:pointer-events-none={!badges_loc.current.fulltext_search_qry_str && class:pointer-events-none={!badges_loc.current.fulltext_search_qry_str &&
!badges_loc.current.search_badge_type_code && !badges_loc.current.search_badge_type_code &&
badges_loc.current.qry_printed_status === 'all'} badges_loc.current.qry_printed_status === 'all' &&
badges_loc.current.visibility_filter === 'default'}
onclick={() => { onclick={() => {
badges_loc.current.fulltext_search_qry_str = ''; badges_loc.current.fulltext_search_qry_str = '';
badges_loc.current.search_badge_type_code = ''; badges_loc.current.search_badge_type_code = '';
badges_loc.current.qry_printed_status = 'all'; badges_loc.current.qry_printed_status = 'all';
badges_loc.current.qry_affiliations = ''; badges_loc.current.qry_affiliations = '';
badges_loc.current.visibility_filter = 'default';
handle_search_trigger(); handle_search_trigger();
document.getElementById('badge_fulltext_search_qry_str')?.focus(); document.getElementById('badge_fulltext_search_qry_str')?.focus();
@@ -274,29 +301,59 @@ function handle_qr_scan_result(event: {
<span class="hidden md:inline"> Clear </span> <span class="hidden md:inline"> Clear </span>
</button> </button>
{#if $ae_loc.trusted_access && $ae_loc.edit_mode}
<label
transition:fade={{ duration: 150 }}
class="bg-surface-200-800 rounded-token flex cursor-pointer items-center gap-1 px-2 py-1 text-xs font-semibold">
<span> Show Hidden </span>
<input
type="checkbox"
bind:checked={badges_loc.current.show_hidden}
onchange={handle_search_trigger}
class="checkbox checkbox-sm" />
</label>
{/if}
{#if $ae_loc.edit_mode} {#if $ae_loc.edit_mode}
{#if limit_steps.length > 1}
<div
transition:fade={{ duration: 150 }}
class="flex flex-row items-center gap-1"
title="Max results (edit mode override)">
<button
type="button"
onclick={() => {
if (limit_idx > 0)
badges_loc.current.qry_result_limit = limit_steps[limit_idx - 1];
}}
disabled={limit_idx <= 0}
class="btn btn-sm preset-outlined-surface-300-700 transition-all px-1
{limit_idx <= 0 ? 'opacity-30 cursor-not-allowed' : 'opacity-60 hover:opacity-100'}">
<Minus size="0.85em" />
</button>
<span class="min-w-8 text-center text-xs font-semibold tabular-nums opacity-80">
{badges_loc.current.qry_result_limit}
</span>
<button
type="button"
onclick={() => {
if (limit_idx < limit_steps.length - 1)
badges_loc.current.qry_result_limit = limit_steps[limit_idx + 1];
}}
disabled={limit_idx >= limit_steps.length - 1}
class="btn btn-sm preset-outlined-surface-300-700 transition-all px-1
{limit_idx >= limit_steps.length - 1 ? 'opacity-30 cursor-not-allowed' : 'opacity-60 hover:opacity-100'}">
<Plus size="0.85em" />
</button>
</div>
{:else}
<span
transition:fade={{ duration: 150 }}
class="text-xs opacity-50"
title="Max results for your access level">
Max: 25
</span>
{/if}
{#if $ae_loc.trusted_access}
<label <label
transition:fade={{ duration: 150 }} transition:fade={{ duration: 150 }}
class="bg-surface-200-800 rounded-token flex cursor-pointer items-center gap-1 px-2 py-1 text-xs font-semibold"> class="bg-surface-200-800 rounded-token flex cursor-pointer items-center gap-1 px-2 py-1 text-xs font-semibold">
<span> Remote First </span> <span>Remote First</span>
<input <input
type="checkbox" type="checkbox"
bind:checked={badges_loc.current.qry__remote_first} bind:checked={badges_loc.current.qry__remote_first}
onchange={handle_search_trigger} onchange={handle_search_trigger}
class="checkbox checkbox-sm" /> class="checkbox checkbox-sm" />
</label> </label>
{/if}
{/if} {/if}
</div> </div>
</form> </form>