mirror of
https://github.com/we-promise/sure.git
synced 2026-06-08 12:19:03 +00:00
fix(sync-toast): morph refresh, defer behind modals, DS conformance (#2105)
* fix(sync-toast): morph refresh, defer behind modals, DS conformance Follow-up to #1964 (addresses #2071). - Refresh via Turbo morph visit instead of window.location.reload, so scroll position and data-turbo-permanent elements (the AI chat panel) survive and there is no white flash. - Defer the toast while a <dialog> is open and reveal it on close. A refresh mid-modal closes the dialog and discards its in-progress input, which is the exact data loss this toast exists to prevent. Handles stacked modals. - Refresh CTA and close button now use DS::Button (secondary / icon). The close is always visible, inside the card, focusable, and has an aria-label; the old hover-only corner chip was unreachable on touch and not keyboard-focusable. - Add role="status" / aria-live="polite" to the toast. - Fix icon color: "inverse" is not a key in the icon helper color map, so it silently rendered no color class (dark icon on bg-info). Use "white", which maps to the functional text-inverse token. - Tighten copy: "New data available" / "Refresh". - Sync the broadcast comment with the actual replace/morph behavior. * fix(sync-toast): detach deferred dialog listener on disconnect A toast replaced by a newer broadcast_replace_to while a <dialog> was open kept its 'close' listener attached, so the detached controller fired #reveal()/#arm() when the dialog closed — a spurious auto-refresh from a stale toast (and repeated syncs could queue several). Store the dialog + handler refs and remove the listener in disconnect(). Flagged by codex + coderabbit on #2105. * fix(sync-toast): re-check interaction and dialogs at refresh-fire time The interaction check ran once at arm time but the refresh fired two seconds later. The post-dialog reveal made that window matter: the user closes a dialog sitting on a form, resumes typing, and the timer morphs the page — wiping non-turbo-permanent input, the exact data-loss class this toast exists to prevent. A dialog opened during the window had the mirror problem (the refresh would close it). Bail inside the callback instead, leaving the toast visible for a manual refresh, matching the mid-form behavior. Also documents the dialog-removed-without-close edge on the deferred listener.
This commit is contained in:
committed by
GitHub
parent
7a0665a8f6
commit
a461ff97bb
@@ -2,32 +2,114 @@ import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// Connects to data-controller="sync-toast"
|
||||
//
|
||||
// Shown when a background sync completes and the family's data changes.
|
||||
// - If the user is not interacting with a form, auto-reloads after a short delay.
|
||||
// - If the user is mid-form, the toast stays visible so they can choose when to refresh.
|
||||
// Shown when a background sync completes and the family's data has changed.
|
||||
// - Idle → morph-refreshes the page after a short delay.
|
||||
// - Mid-form → stays put; the user refreshes when ready.
|
||||
// - A modal <dialog> is open → the toast is deferred (it would otherwise sit
|
||||
// dimmed-but-clickable behind the dialog's top-layer backdrop, and a refresh
|
||||
// would close the dialog and discard its in-progress input). It is revealed
|
||||
// once the dialog closes — the first moment a refresh is actually safe.
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
autoRefreshDelay: { type: Number, default: 2000 },
|
||||
};
|
||||
|
||||
connect() {
|
||||
if (!this.#userIsInteracting()) {
|
||||
this._timer = setTimeout(() => this.refresh(), this.autoRefreshDelayValue);
|
||||
if (this.#dialogOpen()) {
|
||||
this.#deferUntilDialogCloses();
|
||||
return;
|
||||
}
|
||||
this.#arm();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
clearTimeout(this._timer);
|
||||
this.#removeDeferredDialogListener();
|
||||
}
|
||||
|
||||
// Turbo 8 morph refresh (the app sets `turbo_refreshes_with method: :morph,
|
||||
// scroll: :preserve`) instead of window.location.reload(): no white flash,
|
||||
// scroll position and `data-turbo-permanent` elements (the AI chat panel)
|
||||
// are preserved.
|
||||
refresh() {
|
||||
clearTimeout(this._timer);
|
||||
window.location.reload();
|
||||
Turbo.visit(window.location.href, { action: "replace" });
|
||||
}
|
||||
|
||||
#arm() {
|
||||
if (this.#userIsInteracting()) return; // mid-form: wait for a manual refresh
|
||||
// Re-check at fire time, not just arm time: the post-dialog reveal often
|
||||
// lands on a form the dialog was sitting on, and the user resumes typing
|
||||
// inside this window (a morph would wipe their non-turbo-permanent
|
||||
// input). A dialog opened during the window is the same hazard — the
|
||||
// refresh would close it. Either way, bail and leave the toast visible
|
||||
// for a manual refresh, matching the mid-form behavior.
|
||||
this._timer = setTimeout(() => {
|
||||
if (this.#userIsInteracting() || this.#dialogOpen()) return;
|
||||
this.refresh();
|
||||
}, this.autoRefreshDelayValue);
|
||||
}
|
||||
|
||||
#deferUntilDialogCloses() {
|
||||
this.element.style.display = "none";
|
||||
const dialog = document.querySelector("dialog[open]");
|
||||
if (!dialog) {
|
||||
this.#reveal();
|
||||
return;
|
||||
}
|
||||
// Keep refs so disconnect() can detach this listener. Otherwise a toast
|
||||
// replaced by a newer broadcast while the dialog is still open stays
|
||||
// subscribed, and its now-detached controller fires #reveal()/#arm() on
|
||||
// close — a spurious auto-refresh from a stale toast.
|
||||
//
|
||||
// Known edge: if this dialog leaves the DOM without firing `close` (e.g.
|
||||
// a morph removes it), the toast stays hidden until the next broadcast
|
||||
// replaces it. Acceptable: the next sync re-delivers the toast.
|
||||
this._deferredDialog = dialog;
|
||||
this._dialogCloseHandler = () => this.#onDialogClose();
|
||||
dialog.addEventListener("close", this._dialogCloseHandler, { once: true });
|
||||
}
|
||||
|
||||
#onDialogClose() {
|
||||
// The `once` listener has already fired and detached itself.
|
||||
this._deferredDialog = null;
|
||||
this._dialogCloseHandler = null;
|
||||
// Another dialog may still be open (stacked modals) — keep deferring until
|
||||
// every dialog has closed.
|
||||
if (this.#dialogOpen()) {
|
||||
this.#deferUntilDialogCloses();
|
||||
return;
|
||||
}
|
||||
this.#reveal();
|
||||
}
|
||||
|
||||
#removeDeferredDialogListener() {
|
||||
if (this._deferredDialog && this._dialogCloseHandler) {
|
||||
this._deferredDialog.removeEventListener(
|
||||
"close",
|
||||
this._dialogCloseHandler,
|
||||
);
|
||||
}
|
||||
this._deferredDialog = null;
|
||||
this._dialogCloseHandler = null;
|
||||
}
|
||||
|
||||
#reveal() {
|
||||
this.element.style.display = "";
|
||||
this.#arm();
|
||||
}
|
||||
|
||||
#dialogOpen() {
|
||||
return !!document.querySelector("dialog[open]");
|
||||
}
|
||||
|
||||
#userIsInteracting() {
|
||||
const el = document.activeElement;
|
||||
if (!el || el === document.body || el === document.documentElement) return false;
|
||||
return el.isContentEditable || el.closest("form, dialog, [role='dialog']") !== null;
|
||||
if (!el || el === document.body || el === document.documentElement)
|
||||
return false;
|
||||
return (
|
||||
el.isContentEditable ||
|
||||
el.closest("form, dialog, [role='dialog']") !== null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,11 @@ class Family::SyncCompleteEvent
|
||||
end
|
||||
|
||||
def broadcast
|
||||
# Append a lightweight toast to the notification tray instead of a full
|
||||
# page refresh. The sync-toast Stimulus controller handles two cases:
|
||||
# - User is idle → auto-reloads after a short delay
|
||||
# - User is mid-form → toast stays visible; user clicks "Refresh now"
|
||||
# Replace the #sync-toast slot with a lightweight toast instead of a full
|
||||
# page refresh. The sync-toast Stimulus controller handles three cases:
|
||||
# - User is idle → morph-refreshes after a short delay
|
||||
# - User is mid-form → toast stays visible; user clicks "Refresh"
|
||||
# - A modal is open → toast defers until the dialog closes
|
||||
#
|
||||
# This avoids wiping in-progress form state when a background sync fires.
|
||||
# The partial contains no user-scoped data (Current.user is nil here), so
|
||||
|
||||
@@ -1,23 +1,34 @@
|
||||
<div id="sync-toast"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-controller="sync-toast element-removal"
|
||||
class="relative flex gap-3 rounded-lg bg-container p-4 group w-full md:max-w-80 shadow-border-xs">
|
||||
class="relative flex gap-3 rounded-lg bg-container p-4 w-full md:max-w-80 shadow-border-xs">
|
||||
<div class="h-5 w-5 shrink-0 p-px text-primary">
|
||||
<div class="flex h-full items-center justify-center rounded-full bg-info">
|
||||
<%= icon "refresh-cw", size: "xs", color: "inverse" %>
|
||||
<%= icon "refresh-cw", size: "xs", color: "white" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1 flex-1">
|
||||
<div class="space-y-2 flex-1 min-w-0">
|
||||
<p class="text-primary text-sm font-medium"><%= t("shared.sync_toast.message") %></p>
|
||||
<button type="button" class="text-xs text-info underline hover:opacity-80 transition-opacity"
|
||||
data-action="click->sync-toast#refresh">
|
||||
<%= t("shared.sync_toast.refresh") %>
|
||||
</button>
|
||||
<%= render DS::Button.new(
|
||||
text: t("shared.sync_toast.refresh"),
|
||||
variant: "secondary",
|
||||
size: "sm",
|
||||
icon: "refresh-cw",
|
||||
type: "button",
|
||||
data: { action: "sync-toast#refresh" }
|
||||
) %>
|
||||
</div>
|
||||
|
||||
<div class="absolute -top-2 -right-2">
|
||||
<button type="button" class="p-0.5 hidden group-hover:inline-block border border-alpha-black-50 border-solid rounded-lg bg-container text-subdued cursor-pointer" aria-label="<%= t('defaults.common.close') %>" data-action="click->element-removal#remove">
|
||||
<%= icon "x" %>
|
||||
</button>
|
||||
<div class="absolute top-1 right-1">
|
||||
<%= render DS::Button.new(
|
||||
variant: "icon",
|
||||
size: "sm",
|
||||
icon: "x",
|
||||
type: "button",
|
||||
"aria-label": t("defaults.common.close"),
|
||||
data: { action: "click->element-removal#remove" }
|
||||
) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,8 +6,8 @@ en:
|
||||
shared:
|
||||
preview: Preview
|
||||
sync_toast:
|
||||
message: "Your data has been updated"
|
||||
refresh: "Refresh now"
|
||||
message: "New data available"
|
||||
refresh: "Refresh"
|
||||
confirm_modal:
|
||||
accept: Confirm
|
||||
body_html: "<p>You will not be able to undo this decision</p>"
|
||||
|
||||
Reference in New Issue
Block a user