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:
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user