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 ---
|
// --- Accordion: one field section open at a time ---
|
||||||
let active_field: string | null = $state(null);
|
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) {
|
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;
|
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
|
if (active_field && !was_open) {
|
||||||
// accordion closes. The explicit [X] button calls cancel_field directly;
|
// User is trying to switch to a different field.
|
||||||
// clicking away to another field (or re-clicking the pencil) must do the same.
|
if (is_dirty_for_field(active_field) && !pending_close) {
|
||||||
if (active_field) {
|
// Dirty — warn instead of switching. The Save button turns red so
|
||||||
cancel_field(active_field); // resets edit vars + sets active_field = null
|
// 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 ---
|
// --- Editable field state ---
|
||||||
@@ -416,6 +449,7 @@ async function save_field(field_key: string, data_kv: key_val) {
|
|||||||
log_lvl
|
log_lvl
|
||||||
});
|
});
|
||||||
field_save_status = { ...field_save_status, [field_key]: 'done' };
|
field_save_status = { ...field_save_status, [field_key]: 'done' };
|
||||||
|
pending_close = false;
|
||||||
// liveQuery auto-refreshes the badge; close the accordion after a brief confirmation.
|
// liveQuery auto-refreshes the badge; close the accordion after a brief confirmation.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
field_save_status = { ...field_save_status, [field_key]: 'idle' };
|
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) {
|
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) {
|
if (!$lq__event_badge_obj) {
|
||||||
active_field = null;
|
active_field = null;
|
||||||
return;
|
return;
|
||||||
@@ -888,41 +931,54 @@ let allow_tracking_open = $derived(
|
|||||||
title="Remove override — restore original imported value"
|
title="Remove override — restore original imported value"
|
||||||
aria-label="Revert to original value"
|
aria-label="Revert to original value"
|
||||||
aria-hidden={!on_revert}><RotateCcw size="13" /></button>
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-sm flex-1 transition-colors"
|
class="btn btn-sm flex-1 transition-colors"
|
||||||
class:invisible={!save_visible}
|
class:invisible={!save_visible}
|
||||||
class:pointer-events-none={!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 &&
|
is_dirty &&
|
||||||
(!status || status === 'idle')}
|
(!status || status === 'idle')}
|
||||||
class:preset-tonal-surface={status === 'saving'}
|
class:preset-tonal-surface={status === 'saving'}
|
||||||
class:preset-filled-success={status === 'done'}
|
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'}
|
disabled={!save_visible || status === 'saving'}
|
||||||
tabindex={save_visible ? 0 : -1}
|
tabindex={save_visible ? 0 : -1}
|
||||||
onclick={on_save}
|
onclick={on_save}
|
||||||
title="Save changes"
|
title={pending_close ? 'Unsaved changes — save or press × to discard' : 'Save changes'}
|
||||||
aria-label="Save changes"
|
aria-label="Save changes"
|
||||||
aria-hidden={!save_visible}>
|
aria-hidden={!save_visible}>
|
||||||
{#if status === 'saving'}
|
{#if status === 'saving'}
|
||||||
<LoaderCircle size="14" class="mr-1 animate-spin" /> Saving…
|
<LoaderCircle size="14" class="mr-1 animate-spin" /> Saving…
|
||||||
{:else if status === 'done'}
|
{:else if status === 'done'}
|
||||||
<Check size="14" class="mr-1" /> Saved
|
<Check size="14" class="mr-1" /> Saved
|
||||||
{:else if status === 'error'}
|
{:else if status === 'error' && !pending_close}
|
||||||
Error — retry
|
Error — retry
|
||||||
|
{:else if pending_close}
|
||||||
|
Save first (or × to discard)
|
||||||
{:else}
|
{:else}
|
||||||
<Check size="14" class="mr-1" /> Save
|
<Check size="14" class="mr-1" /> Save
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</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
|
<button
|
||||||
type="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}
|
onclick={on_cancel}
|
||||||
title="Cancel"
|
title={pending_close ? 'Discard changes' : 'Cancel'}
|
||||||
aria-label="Cancel editing"><X size="14" /></button>
|
aria-label={pending_close ? 'Discard changes' : 'Cancel editing'}>
|
||||||
|
<X size="14" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user