From ba2887d58e747de96183aef6ef9d2f5f3deb4625 Mon Sep 17 00:00:00 2001 From: Fady Mondy Date: Tue, 15 Sep 2026 09:40:26 +0300 Subject: [PATCH 1/3] Port React foundation fixes to Vue and honour Modal closeOnBackdrop Vue ports of df891a1: - Link: leave modified and non-primary clicks to the browser. - ServerError: restore the previous body overflow on hide() and on unmount. - Laravilt remember/forget: keep the in-memory state when writing to localStorage throws. - Plugin install: fall back to the default prefix/link component name for null or undefined options. Both stacks: - Modal: closeOnBackdrop=false keeps the dialog open on outside clicks (Escape and the close button still close it). Default unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- resources/js/components/Link.vue | 5 +++++ resources/js/components/Modal.vue | 9 ++++++++- resources/js/components/ServerError.vue | 13 +++++++++++-- resources/js/core/Laravilt.js | 12 ++++++++++-- resources/js/core/LaraviltPlugin.js | 5 +++-- resources/react/components/Modal.tsx | 22 ++++++++++++++++++++-- 6 files changed, 57 insertions(+), 9 deletions(-) diff --git a/resources/js/components/Link.vue b/resources/js/components/Link.vue index b6c44ab..4fb0c32 100644 --- a/resources/js/components/Link.vue +++ b/resources/js/components/Link.vue @@ -11,6 +11,11 @@ const props = defineProps({ }); function handleClick(event) { + // Leave modified and non-primary clicks (new tab, new window, download) to the browser + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + return; + } + // Prevent default link behavior event.preventDefault(); event.stopPropagation(); diff --git a/resources/js/components/Modal.vue b/resources/js/components/Modal.vue index d95eba0..a3b0205 100644 --- a/resources/js/components/Modal.vue +++ b/resources/js/components/Modal.vue @@ -34,6 +34,13 @@ const isOpen = computed({ }, }); +// Keep the dialog open on outside clicks when closeOnBackdrop is explicitly false (Escape and the close button still work) +const onInteractOutside = (event: Event) => { + if (props.closeOnBackdrop === false) { + event.preventDefault(); + } +}; + // Get Lucide icon component from icon name const iconComponent = computed(() => { if (!props.icon) return null; @@ -83,7 +90,7 @@ const iconColorClasses = computed(() => { diff --git a/resources/js/core/Laravilt.js b/resources/js/core/Laravilt.js index 59054eb..2b1b993 100644 --- a/resources/js/core/Laravilt.js +++ b/resources/js/core/Laravilt.js @@ -506,7 +506,11 @@ function storeInLocalStorage(key, data) { allData[key] = data; - localStorage.setItem("laravilt", JSON.stringify(allData)); + try { + localStorage.setItem("laravilt", JSON.stringify(allData)); + } catch { + // Keep the in-memory state when persistence is unavailable (quota, blocked storage, unserializable value). + } } /** @@ -531,7 +535,11 @@ function forget(key, useLocalStorage) { delete allData[key]; - localStorage.setItem("laravilt", JSON.stringify(allData)); + try { + localStorage.setItem("laravilt", JSON.stringify(allData)); + } catch { + // Still forget the in-memory copy below when persistence is unavailable. + } } delete rememberedData.value[key]; diff --git a/resources/js/core/LaraviltPlugin.js b/resources/js/core/LaraviltPlugin.js index 28de502..f0f2818 100644 --- a/resources/js/core/LaraviltPlugin.js +++ b/resources/js/core/LaraviltPlugin.js @@ -23,9 +23,10 @@ export default { // Set the default options options = options || {}; options.max_keep_alive = has(options, "max_keep_alive") ? options.max_keep_alive : 10; - options.prefix = has(options, "prefix") ? options.prefix : "Laravilt"; + // Fall back to the defaults for null or undefined, not only for missing keys + options.prefix = options.prefix ?? "Laravilt"; options.transform_anchors = has(options, "transform_anchors") ? options.transform_anchors : false; - options.link_component = has(options, "link_component") ? options.link_component : "Link"; + options.link_component = options.link_component ?? "Link"; options.progress_bar = has(options, "progress_bar") ? options.progress_bar : false; options.components = has(options, "components") ? options.components : {}; options.view_transitions = has(options, "view_transitions") ? options.view_transitions : false; diff --git a/resources/react/components/Modal.tsx b/resources/react/components/Modal.tsx index 25a2b09..8ff103f 100644 --- a/resources/react/components/Modal.tsx +++ b/resources/react/components/Modal.tsx @@ -72,7 +72,18 @@ function getIconComponent(icon?: string): ComponentType<{ className?: string }> return all[icon] || LucideIcons.Circle; } -export default function Modal({ open, title, description, icon, iconColor, onUpdateOpen, onClose, children, footer }: ModalProps) { +export default function Modal({ + open, + title, + description, + closeOnBackdrop, + icon, + iconColor, + onUpdateOpen, + onClose, + children, + footer, +}: ModalProps) { const IconComponent = getIconComponent(icon); const iconColorClasses = iconColor ? iconColorMap[iconColor] || 'text-muted-foreground' : 'text-muted-foreground'; @@ -87,7 +98,14 @@ export default function Modal({ open, title, description, icon, iconColor, onUpd return ( - + { + if (closeOnBackdrop === false) { + event.preventDefault(); + } + }} + > {(title || description || IconComponent) && ( {IconComponent && ( From 243b703a0e9222c9f9c535a146505c4e6d33a6a6 Mon Sep 17 00:00:00 2001 From: Fady Mondy Date: Tue, 15 Sep 2026 10:01:13 +0300 Subject: [PATCH 2/3] fix: scope action-updated-data events to the owning form ActionButton tags the global action-updated-data event with the nearest form scope (event.laraviltFormScope; detail is still the data). Form and the root Schema create a scope id and provide it (Vue provide key laravilt:form-scope, React FormScopeContext); nested Schemas inherit it. Listeners skip events whose scope is set and differs from theirs, so a page with several forms no longer has one action overwrite the others. Co-Authored-By: Claude Opus 5 (1M context) --- resources/react/composables/contexts.tsx | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/resources/react/composables/contexts.tsx b/resources/react/composables/contexts.tsx index 5508ab8..585bf4e 100644 --- a/resources/react/composables/contexts.tsx +++ b/resources/react/composables/contexts.tsx @@ -45,3 +45,25 @@ export const SchemaContext = createContext({}); export function useSchemaContext(): SchemaContextValue { return useContext(SchemaContext); } + +/** + * Id of the nearest form root (Form, or the root Schema). ActionButton tags its + * `action-updated-data` window event with it (`event.laraviltFormScope`) so only that + * form merges the updated data. React twin of Vue `provide('laravilt:form-scope', id)`. + */ +export const FormScopeContext = createContext(null); + +export function useFormScope(): string | null { + return useContext(FormScopeContext); +} + +let formScopeCounter = 0; + +/** + * New unique form scope id (call once per form root, e.g. in a `useState` initializer). + */ +export function createFormScopeId(prefix: string = 'form'): string { + formScopeCounter += 1; + + return `laravilt-${prefix}-${formScopeCounter}`; +} From ba32072e219341fe5e7a250fff22a59711633bf9 Mon Sep 17 00:00:00 2001 From: Fady Mondy Date: Tue, 15 Sep 2026 10:03:34 +0300 Subject: [PATCH 3/3] fix: Link keeps native download/targeted clicks; restore honors failed writes Link returns before preventDefault() when the anchor has download or a non-self target (Vue and React). Laravilt.js tracks local-storage writes that failed (data, or a tombstone for forget) and restore(key, true) prefers that state until a later write succeeds, so it no longer returns stale values or resurrects forgotten ones. Co-Authored-By: Claude Opus 5 (1M context) --- resources/js/components/Link.vue | 7 ++++ resources/js/core/Laravilt.js | 64 +++++++++++++++++++++++------ resources/react/components/Link.tsx | 7 ++++ 3 files changed, 65 insertions(+), 13 deletions(-) diff --git a/resources/js/components/Link.vue b/resources/js/components/Link.vue index 4fb0c32..66eaff0 100644 --- a/resources/js/components/Link.vue +++ b/resources/js/components/Link.vue @@ -16,6 +16,13 @@ function handleClick(event) { return; } + // Leave downloads and links targeting another browsing context (forwarded attrs) to the browser + const anchor = event.currentTarget; + const target = anchor?.getAttribute('target'); + if (anchor?.hasAttribute('download') || (target && target !== '_self')) { + return; + } + // Prevent default link behavior event.preventDefault(); event.stopPropagation(); diff --git a/resources/js/core/Laravilt.js b/resources/js/core/Laravilt.js index 2b1b993..f0960a0 100644 --- a/resources/js/core/Laravilt.js +++ b/resources/js/core/Laravilt.js @@ -479,6 +479,13 @@ function onServerError(html) { const rememberedData = ref({}); +/** + * Local-storage updates that could not be persisted, keyed by name: `{ deleted: false, data }` + * for a failed remember, `{ deleted: true }` (tombstone) for a failed forget. `restore` prefers + * this state over the stale persisted value until a later write for the key succeeds. + */ +const unpersistedData = new Map(); + /** * Stores the given data in the remember-object, and * optionally in the browser's local storage. @@ -487,7 +494,7 @@ function remember(key, data, useLocalStorage) { rememberedData.value[key] = data; if (useLocalStorage) { - storeInLocalStorage(key, data); + trackPersistence(key, storeInLocalStorage(key, data), { deleted: false, data }); } } @@ -495,22 +502,48 @@ function remember(key, data, useLocalStorage) { * Helper method to retrieve the remembered data from the browser's local storage. */ function getLaraviltDataFromLocalStorage() { - return JSON.parse(localStorage.getItem("laravilt") || "{}") || {}; + try { + return JSON.parse(localStorage.getItem("laravilt") || "{}") || {}; + } catch { + // Blocked storage or corrupt JSON: behave as if nothing was persisted. + return {}; + } +} + +/** + * Writes the whole laravilt object to the browser's local storage. Returns whether it succeeded. + */ +function writeLaraviltDataToLocalStorage(allData) { + try { + localStorage.setItem("laravilt", JSON.stringify(allData)); + + return true; + } catch { + // Persistence unavailable (quota, blocked storage, unserializable value). + return false; + } +} + +/** + * Clears the failed-write state for a key once persisted, or records it when the write failed. + */ +function trackPersistence(key, persisted, state) { + if (persisted) { + unpersistedData.delete(key); + } else { + unpersistedData.set(key, state); + } } /** - * Stores the given data in the browser's local storage. + * Stores the given data in the browser's local storage. Returns whether it was persisted. */ function storeInLocalStorage(key, data) { let allData = getLaraviltDataFromLocalStorage(); allData[key] = data; - try { - localStorage.setItem("laravilt", JSON.stringify(allData)); - } catch { - // Keep the in-memory state when persistence is unavailable (quota, blocked storage, unserializable value). - } + return writeLaraviltDataToLocalStorage(allData); } /** @@ -518,6 +551,13 @@ function storeInLocalStorage(key, data) { */ function restore(key, useLocalStorage) { if (useLocalStorage) { + // A failed write wins over the stale persisted value + if (unpersistedData.has(key)) { + const state = unpersistedData.get(key); + + return state.deleted ? undefined : state.data; + } + const laraviltData = getLaraviltDataFromLocalStorage(); return laraviltData[key]; @@ -535,11 +575,9 @@ function forget(key, useLocalStorage) { delete allData[key]; - try { - localStorage.setItem("laravilt", JSON.stringify(allData)); - } catch { - // Still forget the in-memory copy below when persistence is unavailable. - } + // On failure, leave a tombstone so restore() does not resurrect the persisted value. + // The in-memory copy is still forgotten below. + trackPersistence(key, writeLaraviltDataToLocalStorage(allData), { deleted: true }); } delete rememberedData.value[key]; diff --git a/resources/react/components/Link.tsx b/resources/react/components/Link.tsx index 78235b3..af98a46 100644 --- a/resources/react/components/Link.tsx +++ b/resources/react/components/Link.tsx @@ -17,6 +17,13 @@ export default function Link({ href, method = 'GET', children }: LinkProps) { return; } + // Leave downloads and links targeting another browsing context to the browser + const anchor = event.currentTarget; + const target = anchor.getAttribute('target'); + if (anchor.hasAttribute('download') || (target && target !== '_self')) { + return; + } + // Prevent default link behavior event.preventDefault(); event.stopPropagation();