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
2 changes: 1 addition & 1 deletion src/components/settings/Settings.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ function EventLog({ entries }: { entries: LogEntry[] }) {

if (entries.length === 0) return null;
return (
<div className="mt-4 rounded-lg border border-border bg-muted/40 max-h-64 overflow-y-auto">
<div className="mt-4 rounded-sm border border-border bg-muted/40 max-h-64 overflow-y-auto">
<div className="px-3 py-2 border-b border-border bg-muted/60 flex justify-between items-center">
<span className="text-xs font-semibold text-foreground">Event Log</span>
<div className="flex items-center gap-2">
Expand Down
95 changes: 65 additions & 30 deletions src/components/settings/fields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,24 @@ import type { FieldComponentProps, SettingsElement } from "./settings-types";
import { useSettings } from "./settings-context";
import { RawHTML } from '@wordpress/element';

// ============================================
// Danger Surface Palette
// ============================================
//
// Shared by every destructive settings surface — sections flagged `is_danger`
// (see settings-content) and the fields that sit inside them. Fixed brand values
// rather than the theme's `--destructive`, which is tuned for solid fills
// (buttons, switch tracks) and reads too hot as body copy on a pale tint.

/** Card background for a danger block: #E64E61 at 7% alpha. */
export const DANGER_SURFACE = 'bg-[#E64E6112]';

/** Hairline around a danger block: the same #E64E61 at 20% alpha. */
export const DANGER_BORDER = 'border-[#E64E6133]';

/** Title, icon and description color inside a danger block. */
export const DANGER_TEXT = 'text-[#9F2225]';

// ============================================
// Shared Field Wrapper (label + description + tooltip + error)
// ============================================
Expand Down Expand Up @@ -141,20 +159,20 @@ function FieldLabel({ element }: { element: SettingsElement }) {
)}
<div className="flex flex-col gap-1 w-full">
<div className="flex items-center gap-2">
<span className={ cn( 'text-sm font-semibold', element?.is_danger ? 'text-destructive' : 'text-foreground' ) }>
<span className={ cn( 'text-sm font-semibold', element?.is_danger ? DANGER_TEXT : 'text-foreground' ) }>
{displayLabel}
</span>

{IconComponent && (
<IconComponent className={ cn( 'size-4', element?.is_danger ? 'text-destructive' : 'text-primary' ) } />
<IconComponent className={ cn( 'size-4', element?.is_danger ? DANGER_TEXT : 'text-primary' ) } />
)}

{element.tooltip && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<button type="button" className="inline-flex">
<Info className={ cn( 'size-3.5 cursor-help', element?.is_danger ? 'text-destructive' : 'text-muted-foreground' ) } />
<Info className={ cn( 'size-3.5 cursor-help', element?.is_danger ? DANGER_TEXT : 'text-muted-foreground' ) } />
</button>
</TooltipTrigger>
<TooltipContent>
Expand All @@ -165,7 +183,7 @@ function FieldLabel({ element }: { element: SettingsElement }) {
)}
</div>
{element.description && (
<div className={ cn( 'text-xs leading-relaxed', element?.is_danger ? 'text-destructive' : 'text-muted-foreground' ) }>
<div className={ cn( 'text-xs leading-relaxed', element?.is_danger ? DANGER_TEXT : 'text-muted-foreground' ) }>
<RawHTML>{element.description}</RawHTML>
</div>
)}
Expand Down Expand Up @@ -325,7 +343,7 @@ export function GoogleAnalyticsField({ element, onChange, ...rest }: FieldCompon
<FieldWrapper element={element} {...rest}>
<a
href={value?.auth_url || "#"}
className="flex border border-border rounded-md overflow-hidden hover:border-primary transition-colors group h-10"
className="flex border border-border rounded-sm overflow-hidden hover:border-primary transition-colors group h-10"
>
<div className="bg-muted/30 px-3 flex items-center justify-center border-r border-border group-hover:border-primary">
<img
Expand Down Expand Up @@ -428,6 +446,9 @@ export function SelectField({ element, onChange, ...rest }: FieldComponentProps)
return Icon ? <Icon className="size-4 shrink-0" /> : null;
};
const selectedIcon = (selectedOption as { icon?: string } | undefined)?.icon;
const placeholder = element.placeholder
? String(element.placeholder)
: "Select...";

return (
<FieldWrapper element={element} {...rest}>
Expand All @@ -437,14 +458,17 @@ export function SelectField({ element, onChange, ...rest }: FieldComponentProps)
disabled={element.disabled}
>
<SelectTrigger className="sm:max-w-56">
<SelectValue
placeholder={
element.placeholder ? String(element.placeholder) : "Select..."
}
>
{renderOptionIcon(selectedIcon)}
{selectedLabel}
</SelectValue>
{/* Children win over `placeholder`, and with nothing selected they are
both undefined — which renders an empty trigger instead of the
prompt. Only pass children once an option actually matches. */}
{selectedOption ? (
<SelectValue placeholder={placeholder}>
{renderOptionIcon(selectedIcon)}
{selectedLabel}
</SelectValue>
) : (
<SelectValue placeholder={placeholder} />
)}
</SelectTrigger>
<SelectContent>
{element.options?.map((option) => (
Expand Down Expand Up @@ -618,9 +642,12 @@ export function InfoPreviewField({ element, onChange }: FieldComponentProps) {
// ============================================
//
// A dedicated variant for destructive toggles (e.g. "Clear all data on
// uninstall"). Always renders in a destructive-tinted card, always confirms
// the off → on transition via an AlertDialog. Reads `confirm_modal` from the
// schema for modal copy and an optional acknowledgement checkbox.
// uninstall"). Renders destructive-toned copy and always confirms the off → on
// transition via an AlertDialog. Reads `confirm_modal` from the schema for modal
// copy and an optional acknowledgement checkbox. The tinted card around it comes
// from the parent element's `is_danger` flag, not from this field.

// Color comes from DANGER_TEXT above, shared with the danger section wrapper.

export function DangerSwitchField({ element, onChange }: FieldComponentProps) {
const isEnabled = element.enable_state
Expand Down Expand Up @@ -669,21 +696,29 @@ export function DangerSwitchField({ element, onChange }: FieldComponentProps) {
const ackId = `${element.id}-confirm-ack`;

return (
<div className="rounded-lg border border-destructive/20 bg-destructive/10 p-5 flex items-start justify-between gap-4">
<div className="flex flex-col gap-1 flex-1 min-w-0">
// No border/radius/background of its own: like every other field, this one
// paints inside the surface its parent (section / fieldgroup / field-block)
// already draws. Flag that parent `is_danger` in the schema to get the
// destructive tint and border — drawing them here too stacks a second frame
// one pixel inside the first.
<div className="p-5 grid grid-cols-12 gap-4 items-center">
{/* Text column is capped to 8/12 (mirroring FieldWrapper's label grid) so
the copy wraps into a readable measure instead of stretching across a
full-width settings panel on wide screens. */}
<div className="sm:col-span-8 col-span-12 flex flex-col gap-1 min-w-0">
<div className="flex items-center gap-2">
{displayLabel && (
<span className="text-sm font-semibold text-destructive">{displayLabel}</span>
<span className={cn('text-sm font-semibold', DANGER_TEXT)}>{displayLabel}</span>
)}
{IconComponent && <IconComponent className="size-4 text-destructive" />}
{IconComponent && <IconComponent className={cn('size-4', DANGER_TEXT)} />}
</div>
{element.description && (
<div className="text-xs leading-relaxed text-destructive">
<div className={cn('text-sm leading-relaxed', DANGER_TEXT)}>
<RawHTML>{element.description}</RawHTML>
</div>
)}
</div>
<div className="shrink-0 self-center">
<div className="sm:col-span-4 col-span-12 flex sm:justify-end">
<Switch
checked={isEnabled}
onCheckedChange={handleChange}
Expand Down Expand Up @@ -803,7 +838,7 @@ export function MulticheckField({ element, onChange, ...rest }: FieldComponentPr
}
label={option.label ?? option.title}
image={option.image}
className="rounded-[4px]"
className="rounded-sm"
description={
option.description ? (
<div className="flex items-start gap-1.5 mt-1">
Expand Down Expand Up @@ -877,7 +912,7 @@ export function PreviewMulticheckField({ element, onChange, ...rest }: FieldComp
}
label={option.label ?? option.title}
image={option.image}
className="rounded-[4px]"
className="rounded-sm"
description={
option.description ? (
<div className="flex items-start gap-1.5 mt-1">
Expand All @@ -893,7 +928,7 @@ export function PreviewMulticheckField({ element, onChange, ...rest }: FieldComp

{element.image_url && (
<div className="shrink-0 w-full md:w-64 lg:w-80 mt-2 md:mt-0">
<div className="aspect-[4/3] rounded-2xl border border-primary/10 bg-primary/5 flex items-center justify-center p-6 shadow-sm">
<div className="aspect-4/3 rounded-sm border border-primary/10 bg-primary/5 flex items-center justify-center p-6 shadow-sm">
<img src={element.image_url} alt="" className="max-w-full max-h-full object-contain" />
</div>
</div>
Expand All @@ -910,7 +945,7 @@ export function PreviewMulticheckField({ element, onChange, ...rest }: FieldComp
export function LabelField({ element, ...rest }: FieldComponentProps) {
return (
<div
className={cn("p-4 flex justify-between gap-4 items-center", rest.isNested && "!pt-0 !border-t-0 !border-none", rest.isGroupParent && "!pb-0")}
className={cn("p-4 flex justify-between gap-4 items-center", rest.isNested && "pt-0! border-t-0! border-none!", rest.isGroupParent && "pb-0!")}
id={element.id}
data-testid={`settings-field-${element.id}`}
>
Expand All @@ -936,7 +971,7 @@ export function LabelField({ element, ...rest }: FieldComponentProps) {

export function HtmlField({ element, ...rest }: FieldComponentProps) {
return (
<div className={cn("w-full p-4", element.css_class, rest.isNested && "!pt-0 !border-t-0 !border-none", rest.isGroupParent && "!pb-0")} id={element.id} data-testid={`settings-field-${element.id}`}>
<div className={cn("w-full p-4", element.css_class, rest.isNested && "pt-0! border-t-0! border-none!", rest.isGroupParent && "!pb-0")} id={element.id} data-testid={`settings-field-${element.id}`}>
{(element.label || element.title || element.description) && (
<div className="mb-3">
{(element.label || element.title) && (
Expand Down Expand Up @@ -1042,10 +1077,10 @@ export function NoticeField({ element, ...rest }: FieldComponentProps) {
<Alert
variant={alertVariant}
className={cn(
"border rounded-lg p-5",
"border rounded-sm p-5",
element.css_class,
rest.isNested && "!pt-0 !border-t-0 !border-none",
rest.isGroupParent && "!pb-0"
rest.isNested && "pt-0! border-t-0! border-none!",
rest.isGroupParent && "pb-0!"
)}
id={element.id} data-testid={`settings-field-${element.id}`}
>
Expand Down
74 changes: 72 additions & 2 deletions src/components/settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -27,6 +37,10 @@ export function Settings({
applyFilters,
initialPage,
onNavigate,
onDirtyChange,
onDiscardChanges,
confirmOnLeave = true,
unsavedChangesDialog,
searchPlaceholder,
searchable = true,
}: SettingsProps) {
Expand All @@ -42,12 +56,16 @@ export function Settings({
applyFilters={applyFilters}
initialPage={initialPage}
onNavigate={onNavigate}
onDirtyChange={onDirtyChange}
onDiscardChanges={onDiscardChanges}
confirmOnLeave={confirmOnLeave}
>
<SettingsInner
title={title}
className={className}
searchPlaceholder={searchPlaceholder}
searchable={searchable}
unsavedChangesDialog={unsavedChangesDialog}
/>
</SettingsProvider>
);
Expand All @@ -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);
Expand All @@ -86,7 +106,7 @@ function SettingsInner({
return (
<div
className={cn(
'relative flex min-h-125 rounded-lg border border-border bg-background overflow-hidden',
'relative flex min-h-125 rounded-sm border border-border bg-background overflow-hidden',
className
)}
data-testid="settings-root"
Expand Down Expand Up @@ -172,10 +192,60 @@ function SettingsInner({

<SettingsContent className="flex-1" />
</div>

<UnsavedChangesDialog copy={unsavedChangesDialog} />
</div>
);
}

// ============================================
// 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 (
<AlertDialog
open={pendingNavigation !== null}
onOpenChange={(open) => {
if (!open) cancelNavigation();
}}
>
<AlertDialogContent data-testid="settings-unsaved-changes-dialog">
<AlertDialogHeader>
<AlertDialogTitle>
{copy?.title ?? 'Unsaved changes'}
</AlertDialogTitle>
<AlertDialogDescription>
{copy?.description ??
'You have unsaved changes on this page. Leaving now discards them.'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={cancelNavigation}>
{copy?.cancelText ?? 'Stay on this page'}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
/* Pass the target captured in this render: the dialog's own
close runs cancelNavigation first, clearing the state. */
onClick={() => confirmNavigation(pendingNavigation ?? undefined)}
>
{copy?.confirmText ?? 'Discard and leave'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

// ============================================
// Utility: track previous value
// ============================================
Expand Down
Loading
Loading