Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions resources/js/components/Link.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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();
Expand Down
9 changes: 8 additions & 1 deletion resources/js/components/Modal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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<Component | null>(() => {
if (!props.icon) return null;
Expand Down Expand Up @@ -83,7 +90,7 @@ const iconColorClasses = computed(() => {

<template>
<Dialog v-model:open="isOpen">
<DialogContent>
<DialogContent @interact-outside="onInteractOutside">
<DialogHeader v-if="title || description || iconComponent">
<div v-if="iconComponent" class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-muted mb-4">
<component :is="iconComponent" :class="['h-6 w-6', iconColorClasses]" />
Expand Down
13 changes: 11 additions & 2 deletions resources/js/components/ServerError.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
</template>

<script setup>
import { onMounted, ref } from "vue";
import { onBeforeUnmount, onMounted, ref } from "vue";

const props = defineProps({
html: {
Expand All @@ -22,6 +22,8 @@ const props = defineProps({

const emit = defineEmits(["close"]);
const iframeElement = ref(null);
// The body overflow in place before the overlay locked scrolling.
let previousOverflow = "";

/**
* Creates a new HTML element with an iframe holding the HTML from the props.
Expand All @@ -31,6 +33,7 @@ function create() {
page.innerHTML = props.html;
page.querySelectorAll("a").forEach((a) => a.setAttribute("target", "_top"));

previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";

const iframe = iframeElement.value;
Expand Down Expand Up @@ -59,10 +62,16 @@ function keyDownListener($event) {
* Restore the body overflow style and emit the 'close' event.
*/
function hide() {
document.body.style.overflow = "visible";
document.body.style.overflow = previousOverflow;
document.removeEventListener("keydown", keyDownListener);
emit("close");
}

onMounted(() => create());

onBeforeUnmount(() => {
document.removeEventListener("keydown", keyDownListener);
// Don't leave the page scroll-locked when unmounted without hide()
document.body.style.overflow = previousOverflow;
});
</script>
56 changes: 51 additions & 5 deletions resources/js/core/Laravilt.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -487,33 +494,70 @@ function remember(key, data, useLocalStorage) {
rememberedData.value[key] = data;

if (useLocalStorage) {
storeInLocalStorage(key, data);
trackPersistence(key, storeInLocalStorage(key, data), { deleted: false, data });
}
}

/**
* 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);
}

/**
* Retrieves an item from the remember-object or from the browser's local storage.
*/
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];
Expand All @@ -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];
Expand Down
5 changes: 3 additions & 2 deletions resources/js/core/LaraviltPlugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions resources/react/components/Link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
22 changes: 20 additions & 2 deletions resources/react/components/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -87,7 +98,14 @@ export default function Modal({ open, title, description, icon, iconColor, onUpd

return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogContent
// Keep the dialog open on outside clicks when closeOnBackdrop is explicitly false (Escape and the close button still work)
onInteractOutside={(event) => {
if (closeOnBackdrop === false) {
event.preventDefault();
}
}}
>
{(title || description || IconComponent) && (
<DialogHeader>
{IconComponent && (
Expand Down
22 changes: 22 additions & 0 deletions resources/react/composables/contexts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,25 @@ export const SchemaContext = createContext<SchemaContextValue>({});
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<string | null>(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}`;
}