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 */
border-color: rgb(75 85 99); /* gray-600 */
}
.input::placeholder,
.textarea::placeholder {
font-style: italic;
opacity: 0.6;
}
.dark .input::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 */
.dark .select option {

View File

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

View File

@@ -101,6 +101,9 @@ $effect(() => {
let lq__event_badge_obj_li = $derived.by(() => {
const ids = event_badge_id_li;
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 () => {
// SCENARIO 1: Specific IDs provided (Search Results)
@@ -128,7 +131,7 @@ let lq__event_badge_obj_li = $derived.by(() => {
return await db_events.badge
.where('event_id')
.equals(event_id)
.limit(50)
.limit(fallback_limit)
.sortBy('given_name');
}
@@ -169,10 +172,13 @@ let search_params = $derived({
printed: badges_loc.current.qry_printed_status,
aff: (badges_loc.current.qry_affiliations ?? '').toLowerCase().trim(),
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,
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
});
@@ -219,7 +225,9 @@ async function handle_search_refresh(params: any) {
const result_limit = params.result_limit;
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.
// 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')
.equals(event_id)
.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_all && badge.enable === false) return false;
if (type_code && badge.badge_type_code !== type_code)
return false;
@@ -423,6 +432,7 @@ async function handle_search_refresh(params: any) {
type_code: type_code || null,
printed_status: printed_status,
affiliations_qry_str: aff_str || null,
enabled: show_all ? 'all' : 'enabled',
hidden: show_hidden ? 'all' : 'not_hidden',
order_by_li: order_by_li,
limit: result_limit,

View File

@@ -101,9 +101,11 @@ let visible_badge_obj_li = $derived(
if (list === undefined || list === null) return null;
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.
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) => {
if (!item) return false;
@@ -112,6 +114,7 @@ let visible_badge_obj_li = $derived(
// Filter state persists across edit mode toggles — intentional. Staff set
// their filter and it stays regardless of whether edit mode is on or off.
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;
if (ps === '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 {
// Library,
LoaderCircle,
Minus,
Plus,
QrCode,
RemoveFormatting,
Search,
StepForward
} from '@lucide/svelte';
import { fade, slide } from 'svelte/transition';
import { ae_loc, ae_api } from '$lib/stores/ae_stores';
@@ -46,6 +46,19 @@ let badge_type_code_li = [
{ 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.
// Mirrors the 3-tier logic in +page.svelte so both enforce the same threshold.
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}
<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">
<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
bind:value={badges_loc.current.search_badge_type_code}
onchange={handle_search_trigger}
@@ -145,7 +169,7 @@ function handle_qr_scan_result(event: {
<input
type="search"
placeholder="Affiliations"
placeholder="Affiliations/Company"
bind:value={badges_loc.current.qry_affiliations}
onkeyup={(e) => {
if (
@@ -234,11 +258,11 @@ function handle_qr_scan_result(event: {
<div class="flex flex-row items-center justify-center gap-1">
<button
type="submit"
class:opacity-50={($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}
disabled={($events_sess.badges.search_status === 'loading') || (badges_loc.current.fulltext_search_qry_str ?? '').trim().length < effective_min_chars}
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
w-48
disabled:opacity-50 disabled:cursor-not-allowed
">
{#if $events_sess.badges.search_status === 'loading'}
<LoaderCircle class="mx-1 animate-spin" />
@@ -252,15 +276,18 @@ function handle_qr_scan_result(event: {
type="button"
class:opacity-0={!badges_loc.current.fulltext_search_qry_str &&
!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 &&
!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={() => {
badges_loc.current.fulltext_search_qry_str = '';
badges_loc.current.search_badge_type_code = '';
badges_loc.current.qry_printed_status = 'all';
badges_loc.current.qry_affiliations = '';
badges_loc.current.visibility_filter = 'default';
handle_search_trigger();
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>
</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 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
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> Remote First </span>
<span>Remote First</span>
<input
type="checkbox"
bind:checked={badges_loc.current.qry__remote_first}
onchange={handle_search_trigger}
class="checkbox checkbox-sm" />
</label>
{/if}
{/if}
</div>
</form>