diff --git a/resources/js/components/Link.vue b/resources/js/components/Link.vue index b6c44ab..66eaff0 100644 --- a/resources/js/components/Link.vue +++ b/resources/js/components/Link.vue @@ -11,6 +11,18 @@ 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; + } + + // 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/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..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,18 +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; + } } /** - * Stores the given data in the browser's local storage. + * 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. Returns whether it was persisted. */ function storeInLocalStorage(key, data) { let allData = getLaraviltDataFromLocalStorage(); allData[key] = data; - localStorage.setItem("laravilt", JSON.stringify(allData)); + return writeLaraviltDataToLocalStorage(allData); } /** @@ -514,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]; @@ -531,7 +575,9 @@ function forget(key, useLocalStorage) { delete allData[key]; - localStorage.setItem("laravilt", JSON.stringify(allData)); + // 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/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/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(); 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 && ( 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}`; +}