@@ -936,7 +971,7 @@ export function LabelField({ element, ...rest }: FieldComponentProps) {
export function HtmlField({ element, ...rest }: FieldComponentProps) {
return (
-
+
{(element.label || element.title || element.description) && (
{(element.label || element.title) && (
@@ -1042,10 +1077,10 @@ export function NoticeField({ element, ...rest }: FieldComponentProps) {
diff --git a/src/components/settings/index.tsx b/src/components/settings/index.tsx
index 218b2de..02367cf 100644
--- a/src/components/settings/index.tsx
+++ b/src/components/settings/index.tsx
@@ -6,7 +6,17 @@ import { SettingsSidebar } from './settings-sidebar';
import { SettingsContent } from './settings-content';
import { SettingsSkeleton } from './settings-skeleton';
import { useSettings } from './settings-context';
-import type { SettingsProps } from './settings-types';
+import type { SettingsProps, UnsavedChangesDialogCopy } from './settings-types';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '../ui';
import { Menu, X } from 'lucide-react';
import { RawHTML } from "@wordpress/element";
@@ -27,6 +37,10 @@ export function Settings({
applyFilters,
initialPage,
onNavigate,
+ onDirtyChange,
+ onDiscardChanges,
+ confirmOnLeave = true,
+ unsavedChangesDialog,
searchPlaceholder,
searchable = true,
}: SettingsProps) {
@@ -42,12 +56,16 @@ export function Settings({
applyFilters={applyFilters}
initialPage={initialPage}
onNavigate={onNavigate}
+ onDirtyChange={onDirtyChange}
+ onDiscardChanges={onDiscardChanges}
+ confirmOnLeave={confirmOnLeave}
>
);
@@ -62,11 +80,13 @@ function SettingsInner({
className,
searchPlaceholder,
searchable,
+ unsavedChangesDialog,
}: {
title?: string;
className?: string;
searchPlaceholder?: string;
searchable?: boolean;
+ unsavedChangesDialog?: UnsavedChangesDialogCopy;
}) {
const { loading, activeSubpage, isSidebarVisible } = useSettings();
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
@@ -86,7 +106,7 @@ function SettingsInner({
return (
+
+
);
}
+// ============================================
+// Unsaved changes confirm dialog
+// ============================================
+//
+// Driven by the guard in settings-context: while the form is dirty, a sidebar
+// click parks the target in `pendingNavigation` rather than navigating, and this
+// dialog decides whether it goes through. Browser-level exits (tab close, reload,
+// WordPress menu links) can't reach this — those get the native beforeunload
+// prompt the provider registers.
+
+function UnsavedChangesDialog({ copy }: { copy?: UnsavedChangesDialogCopy }) {
+ const { pendingNavigation, confirmNavigation, cancelNavigation } = useSettings();
+
+ return (
+
{
+ if (!open) cancelNavigation();
+ }}
+ >
+
+
+
+ {copy?.title ?? 'Unsaved changes'}
+
+
+ {copy?.description ??
+ 'You have unsaved changes on this page. Leaving now discards them.'}
+
+
+
+
+ {copy?.cancelText ?? 'Stay on this page'}
+
+ confirmNavigation(pendingNavigation ?? undefined)}
+ >
+ {copy?.confirmText ?? 'Discard and leave'}
+
+
+
+
+ );
+}
+
// ============================================
// Utility: track previous value
// ============================================
diff --git a/src/components/settings/settings-content.tsx b/src/components/settings/settings-content.tsx
index d9792f3..02a3750 100644
--- a/src/components/settings/settings-content.tsx
+++ b/src/components/settings/settings-content.tsx
@@ -7,6 +7,7 @@ import { ChevronDown, FileText, Info } from "lucide-react";
import { ScrollArea, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui";
import { Button } from "@/components/ui/button";
import { RawHTML } from "@wordpress/element";
+import { DANGER_BORDER, DANGER_SURFACE, DANGER_TEXT } from "./fields";
// ============================================
// Settings Content — renders heading, tabs, sections
@@ -178,7 +179,7 @@ function ContentBlock({ element }: { element: SettingsElementType }) {
case 'subsection':
return (
-
+
);
@@ -191,14 +192,14 @@ function ContentBlock({ element }: { element: SettingsElementType }) {
return
;
}
return (
-
+
);
case 'fieldgroup':
return (
-
+
);
@@ -243,7 +244,7 @@ function SettingsSection({ section }: { section: SettingsElementType }) {
const HeadingTag: keyof JSX.IntrinsicElements = 'div';
return (
-
+
{hasHeading && (
{sectionLabel && (
-
+
{sectionLabel}
)}
@@ -290,7 +291,7 @@ function SettingsSection({ section }: { section: SettingsElementType }) {
{section.description && (
-
+
{section.description}
)}
diff --git a/src/components/settings/settings-context.tsx b/src/components/settings/settings-context.tsx
index d3f98bc..413a40d 100644
--- a/src/components/settings/settings-context.tsx
+++ b/src/components/settings/settings-context.tsx
@@ -5,6 +5,7 @@ import {
useContext,
useEffect,
useMemo,
+ useRef,
useState,
type ReactNode,
} from 'react';
@@ -24,6 +25,9 @@ import {
/** Filter function signature compatible with @wordpress/hooks applyFilters */
export type ApplyFiltersFunction = (hookName: string, value: any, ...args: any[]) => any;
+/** A sidebar navigation held back by the unsaved-changes guard. */
+export type PendingNavigation = { type: 'page' | 'subpage'; id: string };
+
export interface SettingsContextValue {
/** Parsed hierarchical settings tree */
schema: SettingsElement[];
@@ -67,6 +71,21 @@ export interface SettingsContextValue {
isSidebarVisible: boolean;
/** Check if any field on a specific page has been modified */
isPageDirty: (pageId: string) => boolean;
+ /** True when any tracked field anywhere in the schema differs from its last-saved value */
+ isDirty: boolean;
+ /**
+ * Sidebar navigation the unsaved-changes guard is holding back, or null when
+ * nothing is pending. The Settings root renders its confirm dialog off this.
+ */
+ pendingNavigation: PendingNavigation | null;
+ /**
+ * Discard unsaved changes and perform the held navigation. Pass the target
+ * explicitly when the caller has it — a closing dialog clears the pending
+ * state before its action handler runs.
+ */
+ confirmNavigation: (target?: PendingNavigation) => void;
+ /** Drop the held navigation and stay on the current page */
+ cancelNavigation: () => void;
/** Check if any field on a specific page has a validation error */
hasScopeErrors: (scopeId: string) => boolean;
/** Get only the values that belong to a specific page */
@@ -101,6 +120,22 @@ export interface SettingsProviderProps {
initialPage?: string;
/** Called whenever the active page changes. Use to sync a URL query param. */
onNavigate?: (pageId: string) => void;
+ /**
+ * Called whenever the dirty state flips. Consumers use this to guard their own
+ * router (e.g. React Router's `useBlocker`), which this component can't see.
+ */
+ onDirtyChange?: (dirty: boolean) => void;
+ /**
+ * Called when unsaved changes are discarded (the user chose to leave anyway).
+ * Controlled consumers — those passing `values` — must reset their own state
+ * here, since their values take precedence over this provider's.
+ */
+ onDiscardChanges?: () => void;
+ /**
+ * Hold back sidebar navigation and warn on browser unload while there are
+ * unsaved changes. Default: true.
+ */
+ confirmOnLeave?: boolean;
}
export function SettingsProvider({
@@ -115,6 +150,9 @@ export function SettingsProvider({
applyFilters: applyFiltersProp,
initialPage,
onNavigate,
+ onDirtyChange,
+ onDiscardChanges,
+ confirmOnLeave = true,
}: SettingsProviderProps) {
// Format schema (handles both flat and hierarchical)
const schema = useMemo(() => formatSettingsData(rawSchema), [rawSchema]);
@@ -241,6 +279,37 @@ export function SettingsProvider({
[scopeFieldKeysMap, values, initialValues]
);
+ // Dirty across every scope, not just the visible one — the sidebar can leave a
+ // modified page behind, and a consumer's router guard needs the whole picture.
+ const isDirty = useMemo(
+ () =>
+ Array.from(scopeFieldKeysMap.values()).some((keys) =>
+ keys.some((key) => values[key] !== initialValues[key])
+ ),
+ [scopeFieldKeysMap, values, initialValues]
+ );
+
+ // Report dirtiness outward so consumers can block their own navigation.
+ useEffect(() => {
+ onDirtyChange?.(isDirty);
+ }, [isDirty, onDirtyChange]);
+
+ // Browser-level exits (tab close, reload, WordPress admin menu links) can only
+ // be intercepted through beforeunload, and the browser owns the wording.
+ useEffect(() => {
+ if (!confirmOnLeave || !isDirty) return;
+
+ const handler = (event: BeforeUnloadEvent) => {
+ event.preventDefault();
+ // Legacy browsers need returnValue set to show their prompt.
+ event.returnValue = '';
+ return '';
+ };
+
+ window.addEventListener('beforeunload', handler);
+ return () => window.removeEventListener('beforeunload', handler);
+ }, [confirmOnLeave, isDirty]);
+
// Per-scope error check
const hasScopeErrors = useCallback(
(scopeId: string): boolean => {
@@ -405,8 +474,9 @@ export function SettingsProvider({
[values, idIndex]
);
- // Navigation helpers
- const handleSetActivePage = useCallback(
+ // Navigation helpers — these move immediately. The guarded wrappers further
+ // down are what the sidebar actually calls.
+ const navigateToPage = useCallback(
(pageId: string) => {
setActivePage(pageId);
onNavigate?.(pageId);
@@ -428,7 +498,7 @@ export function SettingsProvider({
[schema, onNavigate]
);
- const handleSetActiveSubpage = useCallback(
+ const navigateToSubpage = useCallback(
(subpageId: string) => {
setActiveSubpage(subpageId);
@@ -461,6 +531,80 @@ export function SettingsProvider({
[schema, activePage]
);
+ // ── Unsaved-changes guard ──
+ //
+ // The sidebar calls the handlers below. While the form is dirty they park the
+ // request in `pendingNavigation` instead of moving, and the Settings root
+ // renders a confirm dialog off that state. Confirming discards the edits and
+ // completes the navigation; cancelling drops the request.
+ const [pendingNavigation, setPendingNavigation] = useState(null);
+ const pendingNavigationRef = useRef(null);
+
+ const runNavigation = useCallback(
+ (target: PendingNavigation) => {
+ if (target.type === 'page') {
+ navigateToPage(target.id);
+ } else {
+ navigateToSubpage(target.id);
+ }
+ },
+ [navigateToPage, navigateToSubpage]
+ );
+
+ const guardNavigation = useCallback(
+ (target: PendingNavigation) => {
+ if (confirmOnLeave && isDirty) {
+ pendingNavigationRef.current = target;
+ setPendingNavigation(target);
+ return;
+ }
+ runNavigation(target);
+ },
+ [confirmOnLeave, isDirty, runNavigation]
+ );
+
+ const handleSetActivePage = useCallback(
+ (pageId: string) => guardNavigation({ type: 'page', id: pageId }),
+ [guardNavigation]
+ );
+
+ const handleSetActiveSubpage = useCallback(
+ (subpageId: string) => guardNavigation({ type: 'subpage', id: subpageId }),
+ [guardNavigation]
+ );
+
+ // Roll every tracked field back to its last-saved value and drop the errors
+ // those edits produced. Only the values this provider owns can be reset; a
+ // consumer passing `values` keeps precedence, which is what `onDiscardChanges`
+ // is for — mirror the reset in your own state.
+ const discardChanges = useCallback(() => {
+ setInternalValues(initialValues);
+ setErrors({});
+ onDiscardChanges?.();
+ }, [initialValues, onDiscardChanges]);
+
+ // Takes the target explicitly because the dialog's own dismissal fires
+ // `onOpenChange` — and therefore `cancelNavigation` — before the action
+ // button's `onClick`, so by then the state is already cleared. The dialog
+ // passes the target it captured when it rendered; the ref is the fallback
+ // for any caller that has none.
+ const confirmNavigation = useCallback(
+ (target?: PendingNavigation) => {
+ const destination = target ?? pendingNavigationRef.current;
+ if (!destination) return;
+ pendingNavigationRef.current = null;
+ setPendingNavigation(null);
+ discardChanges();
+ runNavigation(destination);
+ },
+ [discardChanges, runNavigation]
+ );
+
+ const cancelNavigation = useCallback(() => {
+ pendingNavigationRef.current = null;
+ setPendingNavigation(null);
+ }, []);
+
const getActivePage = useCallback(
() => schema.find((p) => p.id === activePage),
[schema, activePage]
@@ -571,6 +715,10 @@ export function SettingsProvider({
getActiveTabs,
isSidebarVisible,
isPageDirty,
+ isDirty,
+ pendingNavigation,
+ confirmNavigation,
+ cancelNavigation,
hasScopeErrors,
getPageValues,
save: handleOnSave,
@@ -597,6 +745,10 @@ export function SettingsProvider({
getActiveTabs,
isSidebarVisible,
isPageDirty,
+ isDirty,
+ pendingNavigation,
+ confirmNavigation,
+ cancelNavigation,
hasScopeErrors,
getPageValues,
handleOnSave,
diff --git a/src/components/settings/settings-skeleton.tsx b/src/components/settings/settings-skeleton.tsx
index e8cbc73..6a145ee 100644
--- a/src/components/settings/settings-skeleton.tsx
+++ b/src/components/settings/settings-skeleton.tsx
@@ -9,7 +9,7 @@ export function SettingsSkeleton({ className }: { className?: string }) {
return (
{/* Search bar */}
-
+
{/* Nav items */}
@@ -81,7 +81,7 @@ function ContentSkeleton() {
function SectionSkeleton({ fieldCount }: { fieldCount: number }) {
return (
-
+
{/* Section header */}
diff --git a/src/components/settings/settings-types.ts b/src/components/settings/settings-types.ts
index a10b06a..0c6d6f2 100644
--- a/src/components/settings/settings-types.ts
+++ b/src/components/settings/settings-types.ts
@@ -196,12 +196,41 @@ export interface SettingsProps {
initialPage?: string;
/** Called whenever the active page changes. Use to sync a URL query param. */
onNavigate?: (pageId: string) => void;
+ /**
+ * Called whenever the dirty state flips. Use it to guard navigation this
+ * component can't see — e.g. React Router's `useBlocker` for routes outside
+ * the settings screen.
+ */
+ onDirtyChange?: (dirty: boolean) => void;
+ /**
+ * Called when unsaved changes are discarded. Controlled consumers (those
+ * passing `values`) must reset their own state here — their values take
+ * precedence over the internal ones.
+ */
+ onDiscardChanges?: () => void;
+ /**
+ * Hold back sidebar navigation behind a confirm dialog and warn on browser
+ * unload while there are unsaved changes. Default: true.
+ */
+ confirmOnLeave?: boolean;
+ /** Copy overrides for the unsaved-changes confirm dialog (for translation). */
+ unsavedChangesDialog?: UnsavedChangesDialogCopy;
/** Placeholder text for the sidebar search input. */
searchPlaceholder?: string;
/** Show the sidebar search input. Default: true. */
searchable?: boolean;
}
+/** Copy for the unsaved-changes confirm dialog. Every field falls back to English. */
+export interface UnsavedChangesDialogCopy {
+ title?: string;
+ description?: string;
+ /** Label on the "leave anyway" action. */
+ confirmText?: string;
+ /** Label on the "stay here" action. */
+ cancelText?: string;
+}
+
export interface FieldComponentProps {
element: SettingsElement;
onChange: (key: string, value: any) => void;
diff --git a/src/components/ui/rich-text-editor.tsx b/src/components/ui/rich-text-editor.tsx
index 6ae38c3..619c256 100644
--- a/src/components/ui/rich-text-editor.tsx
+++ b/src/components/ui/rich-text-editor.tsx
@@ -14,10 +14,37 @@ import {
Redo,
Sparkles,
MoreVertical,
+ ChevronDown,
} from "lucide-react";
import { cn } from "@/lib/utils";
+// A native
picks up the host application's select styling — WordPress
+// admin, for one, paints its own arrow at the right edge — which lands on top of
+// the label when the control only reserves 8px of end padding. Suppress the host
+// arrow (`appearance-none bg-none`), reserve space, and draw our own chevron so
+// the toolbar looks identical wherever the editor is embedded.
+function ToolbarSelect({
+ className,
+ children,
+ ...props
+}: React.ComponentProps<"select">) {
+ return (
+
+
+ {children}
+
+
+
+ );
+}
+
interface RichTextEditorContentAction {
show?: boolean;
showContent?: boolean;
@@ -140,38 +167,26 @@ function RichTextEditor({
>
)}
-
+
Paragraph
Heading 1
Heading 2
{variant === "full" && Heading 3 }
-
+
-
+
Sans Serif
Serif
{variant === "full" && Monospace }
-
+
{variant === "full" && (
-
+
12 px
14 px
16 px
18 px
-
+
)}
diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx
index d51e413..74708e0 100644
--- a/src/components/ui/sidebar.tsx
+++ b/src/components/ui/sidebar.tsx
@@ -336,10 +336,10 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
- "hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 group-data-mounted/sidebar-wrapper:transition-all group-data-mounted/sidebar-wrapper:ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
+ "hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 group-data-mounted/sidebar-wrapper:transition-all group-data-mounted/sidebar-wrapper:ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-0.5 sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize rtl:in-data-[side=left]:cursor-e-resize in-data-[side=right]:cursor-e-resize rtl:in-data-[side=right]:cursor-w-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize rtl:[[data-side=left][data-state=collapsed]_&]:cursor-w-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize rtl:[[data-side=right][data-state=collapsed]_&]:cursor-e-resize",
- "hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 rtl:group-data-[collapsible=offcanvas]:-translate-x-0 group-data-[collapsible=offcanvas]:after:start-full",
+ "hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 rtl:group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:start-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-end-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-start-2",
className
@@ -725,7 +725,7 @@ function SidebarMenuSubButton({
props: mergeProps<"a">(
{
className: cn(
- "w-full text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground data-active:bg-primary/10 data-active:text-primary data-active:[&>svg]:text-primary data-active:font-medium h-7 gap-2 rounded-md px-2 focus-visible:ring-2 data-[size=md]:text-sm data-[size=sm]:text-xs [&>svg]:size-4 flex min-w-0 -translate-x-px rtl:translate-x-px items-center overflow-hidden outline-hidden group-data-[collapsible=icon]:hidden disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:shrink-0",
+ "w-full text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground data-active:bg-primary/10 data-active:text-primary data-active:[&>svg]:text-primary data-active:font-medium h-8 gap-2 rounded-sm px-2 focus-visible:ring-2 data-[size=md]:text-sm data-[size=sm]:text-xs [&>svg]:size-4 flex min-w-0 -translate-x-px rtl:translate-x-px items-center overflow-hidden outline-hidden group-data-[collapsible=icon]:hidden disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:shrink-0",
className
),
},
diff --git a/src/components/wordpress/layout-menu.tsx b/src/components/wordpress/layout-menu.tsx
index b263599..d508411 100644
--- a/src/components/wordpress/layout-menu.tsx
+++ b/src/components/wordpress/layout-menu.tsx
@@ -555,7 +555,14 @@ function MenuItemRenderer({
)}
>
-