fix(badges): guard unsaved edits — warn on close, error on second close

When a field accordion has unsaved changes and the user tries to close
(X button, same-header click, or switching to another field), we now set
pending_close = true instead of silently discarding.

- Save button turns bright red + animate-pulse with label "Save first (or × to discard)"
- X button turns red with "Discard changes" tooltip
- Field stays open — no data is lost
- Second close attempt (pending_close already true) actually discards
- Saving normally clears pending_close and closes the accordion

WHY: kiosk attendees at a live event were silently losing typed overrides
(professional title, affiliations, etc.) when switching fields mid-queue.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-04-14 17:07:26 -04:00
parent 126eb77be2
commit 0199c2e2c9

View File

@@ -341,20 +341,53 @@ function font_size_reset(
// --- Accordion: one field section open at a time ---
let active_field: string | null = $state(null);
// --- Unsaved-change guard ---
// When the user tries to close or switch away from a dirty field, we DON'T
// silently discard. Instead we set pending_close = true, which turns the Save
// button bright red + pulsing so they notice they have unsaved changes.
// A second close attempt (while pending_close is already true) actually discards.
// WHY: kiosk attendees at a conference are confused when changes disappear without
// warning; they can't lose a professional title or affiliation mid-queue.
let pending_close = $state(false);
/** Returns the dirty state for the given field key. */
function is_dirty_for_field(field_key: string): boolean {
switch (field_key) {
case 'name': return is_dirty_name;
case 'title': return is_dirty_title;
case 'affiliations': return is_dirty_affiliations;
case 'location': return is_dirty_location;
case 'pronouns': return is_dirty_pronouns;
case 'allow_tracking': return is_dirty_allow_tracking;
case 'badge_type': return is_dirty_badge_type;
default: return false;
}
}
function toggle_field(key: string) {
// Capture whether the caller is closing the field that's already open.
// Capture whether the caller is re-clicking the field that's already open.
const was_open = active_field === key;
// Always cancel the current field before switching — discards any unsaved edit
// state so stale typed values don't bleed into the badge preview after the
// accordion closes. The explicit [X] button calls cancel_field directly;
// clicking away to another field (or re-clicking the pencil) must do the same.
if (active_field) {
cancel_field(active_field); // resets edit vars + sets active_field = null
if (active_field && !was_open) {
// User is trying to switch to a different field.
if (is_dirty_for_field(active_field) && !pending_close) {
// Dirty — warn instead of switching. The Save button turns red so
// the user knows they need to save (or cancel) the current field first.
pending_close = true;
return; // block the switch
}
// Either clean, or second attempt after warning — discard and switch.
cancel_field(active_field);
}
if (!was_open) {
active_field = key; // open the new field
if (was_open) {
// Clicking the same field header again: same guard as the X button.
cancel_field(key);
return;
}
// If was_open: active_field stays null — the field is now closed.
pending_close = false;
active_field = key;
}
// --- Editable field state ---
@@ -416,6 +449,7 @@ async function save_field(field_key: string, data_kv: key_val) {
log_lvl
});
field_save_status = { ...field_save_status, [field_key]: 'done' };
pending_close = false;
// liveQuery auto-refreshes the badge; close the accordion after a brief confirmation.
setTimeout(() => {
field_save_status = { ...field_save_status, [field_key]: 'idle' };
@@ -434,6 +468,15 @@ async function save_field(field_key: string, data_kv: key_val) {
}
function cancel_field(field_key: string) {
// First cancel attempt while dirty → warn, don't discard.
// Second attempt (pending_close already set) → actually discard.
if (is_dirty_for_field(field_key) && !pending_close) {
pending_close = true;
return;
}
pending_close = false;
if (!$lq__event_badge_obj) {
active_field = null;
return;
@@ -888,41 +931,54 @@ let allow_tracking_open = $derived(
title="Remove override restore original imported value"
aria-label="Revert to original value"
aria-hidden={!on_revert}><RotateCcw size="13" /></button>
<!-- Save: always in centre slot; invisible+inert when clean -->
<!-- Save: always in centre slot; invisible+inert when clean.
Turns bright red + pulses when pending_close is set — the user tried
to close while dirty. They must save OR press X again to discard. -->
<button
type="button"
class="btn btn-sm flex-1 transition-colors"
class:invisible={!save_visible}
class:pointer-events-none={!save_visible}
class:preset-filled-warning={save_visible &&
class:preset-filled-error={pending_close && is_dirty && (!status || status === 'idle')}
class:animate-pulse={pending_close && is_dirty && (!status || status === 'idle')}
class:preset-filled-warning={!pending_close &&
save_visible &&
is_dirty &&
(!status || status === 'idle')}
class:preset-tonal-surface={status === 'saving'}
class:preset-filled-success={status === 'done'}
class:preset-tonal-error={status === 'error'}
class:preset-tonal-error={status === 'error' && !pending_close}
disabled={!save_visible || status === 'saving'}
tabindex={save_visible ? 0 : -1}
onclick={on_save}
title="Save changes"
title={pending_close ? 'Unsaved changes — save or press × to discard' : 'Save changes'}
aria-label="Save changes"
aria-hidden={!save_visible}>
{#if status === 'saving'}
<LoaderCircle size="14" class="mr-1 animate-spin" /> Saving…
{:else if status === 'done'}
<Check size="14" class="mr-1" /> Saved
{:else if status === 'error'}
{:else if status === 'error' && !pending_close}
Error — retry
{:else if pending_close}
Save first (or × to discard)
{:else}
<Check size="14" class="mr-1" /> Save
{/if}
</button>
<!-- Cancel: always visible at right end -->
<!-- Cancel: always visible at right end.
When pending_close is active the label changes to "Discard" so the
user knows a second tap will throw away their edit. -->
<button
type="button"
class="btn btn-sm preset-tonal-surface"
class="btn btn-sm"
class:preset-tonal-surface={!pending_close}
class:preset-tonal-error={pending_close}
onclick={on_cancel}
title="Cancel"
aria-label="Cancel editing"><X size="14" /></button>
title={pending_close ? 'Discard changes' : 'Cancel'}
aria-label={pending_close ? 'Discard changes' : 'Cancel editing'}>
<X size="14" />
</button>
</div>
{/snippet}