>(initialValues);
+ const { entries, log } = useEventLog();
+
+ return (
+
+
+ {
+ setValues((prev) => ({ ...prev, [key]: value }));
+ log({ type: 'change', pageId: scopeId, key, value });
+ }}
+ onSave={(scopeId, scopeValues) => {
+ // eslint-disable-next-line no-console
+ console.log(`Save scope "${scopeId}":`, scopeValues);
+ log({ type: 'save', pageId: scopeId, values: scopeValues });
+ }}
+ renderSaveButton={({ dirty, onSave: save }) => (
+
+
+ Save Changes
+
+ )}
+ />
+
+
+
+ );
+}
+
+// ============================================
+// Stories
+// ============================================
+
+/** Full settings page with sidebar navigation, tabs, sections, and various field types. */
+export const Default: Story = {
+ args: {
+ schema: sampleSchema,
+ title: 'Settings',
+ loading: false,
+ hookPrefix: 'my_plugin',
+ },
+ render: (args) => ,
+};
+
+/** Loading state. */
+export const Loading: Story = {
+ args: {
+ schema: sampleSchema,
+ loading: true,
+ title: 'Settings',
+ },
+};
+
+/** With pre-populated values. */
+export const WithValues: Story = {
+ args: {
+ schema: sampleSchema,
+ title: 'Acme Store Settings',
+ },
+ render: (args) => (
+
+ ),
+};
+
+/** Dependency demo — toggle the switch to show/hide dependent fields. */
+export const DependencyDemo: Story = {
+ args: {
+ schema: sampleSchema,
+ title: 'Dependency Demo',
+ },
+ render: (args) => (
+
+ ),
+};
+
+// ============================================
+// Flat Array Stories
+// ============================================
+
+/**
+ * Flat array schema — the formatter auto-builds the hierarchy.
+ *
+ * Exercises: pages, subpages, tabs, sections, subsections, fieldgroups,
+ * fields directly under subpages (no section), and dependency-based
+ * subsection visibility.
+ */
+export const FlatArray: Story = {
+ args: {
+ schema: flatSampleSchema,
+ title: 'Flat Array Settings',
+ hookPrefix: 'flat_demo',
+ },
+ render: (args) => ,
+};
+
+/** Flat array with pre-populated values. */
+export const FlatArrayWithValues: Story = {
+ args: {
+ schema: flatSampleSchema,
+ title: 'Flat Array (Pre-populated)',
+ },
+ render: (args) => (
+
+ ),
+};
+
+// ============================================
+// Single Page (no sidebar) — page without subpages
+// ============================================
+
+const singlePageSchema: SettingsElement[] = [
+ {
+ id: 'email_settings',
+ type: 'page',
+ label: 'Email Settings',
+ description: 'Configure email notification preferences.',
+ icon: 'Mail',
+ children: [
+ {
+ id: 'notifications_section',
+ type: 'section',
+ label: 'Notifications',
+ children: [
+ {
+ id: 'admin_email',
+ type: 'field',
+ variant: 'text',
+ label: 'Admin Email',
+ description: 'Primary email for admin notifications.',
+ default: 'admin@example.com',
+ dependency_key: 'admin_email',
+ },
+ {
+ id: 'enable_notifications',
+ type: 'field',
+ variant: 'switch',
+ label: 'Enable Notifications',
+ description: 'Send email notifications for new orders.',
+ default: true,
+ dependency_key: 'enable_notifications',
+ },
+ {
+ id: 'notification_frequency',
+ type: 'field',
+ variant: 'select',
+ label: 'Frequency',
+ default: 'instant',
+ options: [
+ { value: 'instant', label: 'Instant' },
+ { value: 'hourly', label: 'Hourly Digest' },
+ { value: 'daily', label: 'Daily Digest' },
+ ],
+ dependency_key: 'notification_frequency',
+ dependencies: [{ key: 'enable_notifications', value: true, comparison: '==' }],
+ },
+ ],
+ },
+ ],
+ },
+];
+
+/**
+ * Single page with no subpages — sidebar is auto-hidden.
+ * Demonstrates that the menu bar is hidden when there is only one navigable item.
+ */
+export const SinglePage: Story = {
+ args: {
+ schema: singlePageSchema,
+ title: 'Email Settings',
+ },
+ render: (args) => ,
+};
+
+// ============================================
+// Mixed: pages with and without subpages
+// ============================================
+
+const mixedSchema: SettingsElement[] = [
+ // Page WITH subpages
+ {
+ id: 'general',
+ type: 'page',
+ label: 'General',
+ icon: 'Settings',
+ children: [
+ {
+ id: 'store',
+ type: 'subpage',
+ label: 'Store Settings',
+ icon: 'Store',
+ children: [
+ {
+ id: 'store_section',
+ type: 'section',
+ label: 'Store Info',
+ children: [
+ {
+ id: 'store_name',
+ type: 'field',
+ variant: 'text',
+ label: 'Store Name',
+ default: 'My Store',
+ dependency_key: 'store_name',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ id: 'appearance',
+ type: 'subpage',
+ label: 'Appearance',
+ icon: 'Palette',
+ children: [
+ {
+ id: 'appearance_section',
+ type: 'section',
+ label: 'Theme',
+ children: [
+ {
+ id: 'color_scheme',
+ type: 'field',
+ variant: 'radio_capsule',
+ label: 'Color Scheme',
+ default: 'light',
+ options: [
+ { value: 'light', label: 'Light' },
+ { value: 'dark', label: 'Dark' },
+ { value: 'auto', label: 'Auto' },
+ ],
+ dependency_key: 'color_scheme',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ // Page WITHOUT subpages — appears as a leaf menu item alongside "General"
+ {
+ id: 'about',
+ type: 'page',
+ label: 'About',
+ description: 'Plugin information and version details.',
+ icon: 'Info',
+ children: [
+ {
+ id: 'about_section',
+ type: 'section',
+ label: 'Version',
+ children: [
+ {
+ id: 'version_info',
+ type: 'field',
+ variant: 'html',
+ label: 'Current Version',
+ html_content: 'v2.5.0 — Released Feb 2026
',
+ },
+ ],
+ },
+ ],
+ },
+];
+
+/**
+ * Mixed schema: one page with subpages + one page without.
+ * Demonstrates pages with and without submenus coexisting in the sidebar.
+ */
+export const MixedPages: Story = {
+ args: {
+ schema: mixedSchema,
+ title: 'Plugin Settings',
+ },
+ render: (args) => ,
+};
diff --git a/src/components/settings/field-renderer.tsx b/src/components/settings/field-renderer.tsx
new file mode 100644
index 0000000..5b73084
--- /dev/null
+++ b/src/components/settings/field-renderer.tsx
@@ -0,0 +1,128 @@
+import type { SettingsElement, FieldComponentProps } from './settings-types';
+import { useSettings } from './settings-context';
+import {
+ TextField,
+ NumberField,
+ TextareaField,
+ SelectField,
+ SwitchField,
+ RadioCapsuleField,
+ CustomizeRadioField,
+ MulticheckField,
+ LabelField,
+ HtmlField,
+ FallbackField,
+} from './fields';
+
+// ============================================
+// Field Renderer — dispatches by variant
+// Wraps each variant with applyFilters from context
+// (consumer passes applyFilters via Settings props,
+// e.g. @wordpress/hooks applyFilters or a custom function)
+// ============================================
+
+export function FieldRenderer({ element }: { element: SettingsElement }) {
+ const { values, updateValue, shouldDisplay, hookPrefix, errors, applyFilters } = useSettings();
+
+ // Check display status (dependency evaluation)
+ if (!shouldDisplay(element)) {
+ return null;
+ }
+
+ // Merge current value from context
+ const mergedElement: SettingsElement = {
+ ...element,
+ value: element.dependency_key ? (values[element.dependency_key] ?? element.value) : element.value,
+ validationError: element.dependency_key ? errors[element.dependency_key] : undefined,
+ };
+
+ const fieldProps: FieldComponentProps = {
+ element: mergedElement,
+ onChange: updateValue,
+ };
+
+ const variant = element.variant || '';
+ const filterPrefix = hookPrefix || 'plugin_ui';
+
+ // Dispatch by variant — each wrapped with applyFilters
+ switch (variant) {
+ case 'text':
+ return applyFilters(
+ `${filterPrefix}_settings_text_field`,
+ ,
+ mergedElement
+ );
+
+ case 'number':
+ return applyFilters(
+ `${filterPrefix}_settings_number_field`,
+ ,
+ mergedElement
+ );
+
+ case 'textarea':
+ return applyFilters(
+ `${filterPrefix}_settings_textarea_field`,
+ ,
+ mergedElement
+ );
+
+ case 'select':
+ return applyFilters(
+ `${filterPrefix}_settings_select_field`,
+ ,
+ mergedElement
+ );
+
+ case 'switch':
+ return applyFilters(
+ `${filterPrefix}_settings_switch_field`,
+ ,
+ mergedElement
+ );
+
+ case 'radio_capsule':
+ return applyFilters(
+ `${filterPrefix}_settings_radio_capsule_field`,
+ ,
+ mergedElement
+ );
+
+ case 'customize_radio':
+ return applyFilters(
+ `${filterPrefix}_settings_customize_radio_field`,
+ ,
+ mergedElement
+ );
+
+ case 'multicheck':
+ case 'checkbox_group':
+ return applyFilters(
+ `${filterPrefix}_settings_multicheck_field`,
+ ,
+ mergedElement
+ );
+
+ case 'base_field_label':
+ return applyFilters(
+ `${filterPrefix}_settings_label_field`,
+ ,
+ mergedElement
+ );
+
+ case 'html':
+ return applyFilters(
+ `${filterPrefix}_settings_html_field`,
+ ,
+ mergedElement
+ );
+
+ default:
+ // Unknown variant — consumer must handle via applyFilters
+ return applyFilters(
+ `${filterPrefix}_settings_default_field`,
+ ,
+ mergedElement
+ );
+ }
+}
diff --git a/src/components/settings/fields.tsx b/src/components/settings/fields.tsx
new file mode 100644
index 0000000..f3eb6c4
--- /dev/null
+++ b/src/components/settings/fields.tsx
@@ -0,0 +1,465 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { cn } from "@/lib/utils";
+import { FileText, Info } from "lucide-react";
+import { Checkbox } from "../ui/checkbox";
+import { Input } from "../ui/input";
+import { RadioCard, RadioGroup } from "../ui/radio-group";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "../ui/select";
+import { Switch } from "../ui/switch";
+import { Textarea } from "../ui/textarea";
+import { ToggleGroup, ToggleGroupItem } from "../ui/toggle-group";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "../ui/tooltip";
+import type { FieldComponentProps, SettingsElement } from "./settings-types";
+
+// ============================================
+// Shared Field Wrapper (label + description + tooltip + error)
+// ============================================
+
+function FieldWrapper({
+ element,
+ children,
+ layout = "horizontal",
+ className,
+}: {
+ element: SettingsElement;
+ children: React.ReactNode;
+ layout?: "horizontal" | "vertical" | "full-width";
+ className?: string;
+}) {
+ const hasLabel = Boolean(
+ (element.label && element.label.length > 0) ||
+ (element.title && element.title.length > 0),
+ );
+
+ if (layout === "full-width") {
+ return (
+
+ {hasLabel &&
}
+
{children}
+ {element.validationError && (
+
{element.validationError}
+ )}
+
+ );
+ }
+
+ return (
+
+ {hasLabel && (
+
+
+
+ )}
+
+ {children}
+
+ {element.validationError && (
+
+
{element.validationError}
+
+ )}
+
+ );
+}
+
+function FieldLabel({ element }: { element: SettingsElement }) {
+ const displayLabel = element.label || element.title || '';
+
+ return (
+
+
+ {element.image_url && (
+
+ )}
+
+ {displayLabel}
+
+ {element.tooltip && (
+
+
+
+
+
+
+
+
+ {element.tooltip}
+
+
+
+ )}
+
+ {element.description && (
+
+ {element.description}
+
+ )}
+
+ );
+}
+
+// ============================================
+// Text Field
+// ============================================
+
+export function TextField({ element, onChange }: FieldComponentProps) {
+ return (
+
+ onChange(element.dependency_key!, e.target.value)}
+ placeholder={
+ element.placeholder ? String(element.placeholder) : undefined
+ }
+ disabled={element.disabled}
+ className="sm:max-w-56"
+ />
+
+ );
+}
+
+// ============================================
+// Number Field
+// ============================================
+
+export function NumberField({ element, onChange }: FieldComponentProps) {
+ return (
+
+
+ {element.prefix && (
+
+ {element.prefix}
+
+ )}
+
+ onChange(
+ element.dependency_key!,
+ e.target.value === "" ? "" : Number(e.target.value),
+ )
+ }
+ placeholder={
+ element.placeholder ? String(element.placeholder) : undefined
+ }
+ disabled={element.disabled}
+ min={element.min}
+ max={element.max}
+ step={element.increment}
+ />
+ {element.postfix && (
+
+ {element.postfix}
+
+ )}
+
+
+ );
+}
+
+// ============================================
+// Textarea Field
+// ============================================
+
+export function TextareaField({ element, onChange }: FieldComponentProps) {
+ return (
+
+
+ );
+}
+
+// ============================================
+// Select Field
+// ============================================
+
+export function SelectField({ element, onChange }: FieldComponentProps) {
+ const currentValue = String(element.value ?? element.default ?? "");
+ const selectedOption = element.options?.find(
+ (o) => String(o.value) === currentValue
+ );
+ const selectedLabel = selectedOption?.label ?? selectedOption?.title;
+
+ return (
+
+ onChange(element.dependency_key!, val)}
+ disabled={element.disabled}
+ >
+
+
+ {selectedLabel}
+
+
+
+ {element.options?.map((option) => (
+
+ {option.label ?? option.title}
+
+ ))}
+
+
+
+ );
+}
+
+// ============================================
+// Switch Field
+// ============================================
+
+export function SwitchField({ element, onChange }: FieldComponentProps) {
+ const isEnabled = element.enable_state
+ ? element.value === element.enable_state.value
+ : Boolean(element.value);
+
+ const handleChange = (checked: boolean) => {
+ if (element.enable_state && element.disable_state) {
+ onChange(
+ element.dependency_key!,
+ checked ? element.enable_state.value : element.disable_state.value,
+ );
+ } else {
+ onChange(element.dependency_key!, checked);
+ }
+ };
+
+ return (
+
+
+
+ );
+}
+
+// ============================================
+// Radio Capsule Field (using ToggleGroup)
+// ============================================
+
+export function RadioCapsuleField({ element, onChange }: FieldComponentProps) {
+ const currentValue = String(element.value ?? element.default ?? "");
+
+ return (
+
+ {
+ const selected = Array.isArray(val) ? val[0] : val;
+ if (selected) onChange(element.dependency_key!, selected);
+ }}
+ >
+ {element.options?.map((option) => (
+
+ {option.label ?? option.title}
+
+ ))}
+
+
+ );
+}
+
+// ============================================
+// Multicheck Field
+// ============================================
+
+export function MulticheckField({ element, onChange }: FieldComponentProps) {
+ const currentValues: string[] = Array.isArray(element.value)
+ ? element.value.map(String)
+ : Array.isArray(element.default)
+ ? (element.default as any[]).map(String)
+ : [];
+
+ const handleToggle = (optionValue: string, checked: boolean) => {
+ const updated = checked
+ ? [...currentValues, optionValue]
+ : currentValues.filter((v) => v !== optionValue);
+ onChange(element.dependency_key!, updated);
+ };
+
+ return (
+
+
+ {element.options?.map((option) => (
+
+
+ handleToggle(String(option.value), Boolean(checked))
+ }
+ />
+ {option.label ?? option.title}
+
+ ))}
+
+
+ );
+}
+
+// ============================================
+// Label-only Field (base_field_label)
+// ============================================
+
+export function LabelField({ element }: FieldComponentProps) {
+ return (
+
+
+ {element.doc_link && (
+
+
+ Doc
+
+ )}
+
+ );
+}
+
+// ============================================
+// HTML Field
+// ============================================
+
+export function HtmlField({ element }: FieldComponentProps) {
+ return (
+
+ {(element.label || element.title || element.description) && (
+
+ {(element.label || element.title) && (
+
+ {element.label || element.title}
+
+ )}
+ {element.description && (
+
+ {element.description}
+
+ )}
+
+ )}
+ {element.html_content && (
+
+ )}
+
+ );
+}
+
+// ============================================
+// Customize Radio Field (RadioCard)
+// ============================================
+
+export function CustomizeRadioField({
+ element,
+ onChange,
+}: FieldComponentProps) {
+ const currentValue = String(element.value ?? element.default ?? "");
+
+ return (
+
+ onChange(element.dependency_key!, val)}
+ className={cn(
+ "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3",
+ "[&>[data-slot=field-group]]:h-full",
+ "[&_[data-slot=field-label]]:h-full [&_[data-slot=field-label]]:w-full",
+ "[&_[data-slot=field]]:h-full",
+ )}
+ >
+ {element.options?.map((option) => (
+
+ ))}
+
+
+ );
+}
+
+// ============================================
+// Fallback Field (for unknown variants)
+// ============================================
+
+export function FallbackField({ element }: FieldComponentProps) {
+ return (
+
+ Unsupported field type:{" "}
+
+ {element.variant}
+
+ {(element.label || element.title) && (
+ — {element.label || element.title}
+ )}
+
+ );
+}
+
+export { FieldLabel, FieldWrapper };
diff --git a/src/components/settings/index.tsx b/src/components/settings/index.tsx
new file mode 100644
index 0000000..c96e566
--- /dev/null
+++ b/src/components/settings/index.tsx
@@ -0,0 +1,190 @@
+import { useEffect, useState } from 'react';
+import { cn } from '@/lib/utils';
+import { Button } from '../ui/button';
+import { SettingsProvider } from './settings-context';
+import { SettingsSidebar } from './settings-sidebar';
+import { SettingsContent } from './settings-content';
+import { useSettings } from './settings-context';
+import type { SettingsProps } from './settings-types';
+import { Menu, X } from 'lucide-react';
+
+// ============================================
+// Settings Root Component
+// ============================================
+
+export function Settings({
+ schema,
+ values,
+ onChange,
+ onSave,
+ renderSaveButton,
+ loading = false,
+ title,
+ hookPrefix = 'plugin_ui',
+ className,
+ applyFilters,
+}: SettingsProps) {
+ return (
+
+
+
+ );
+}
+
+// ============================================
+// Inner component (has access to context)
+// ============================================
+
+function SettingsInner({
+ title,
+ className,
+}: {
+ title?: string;
+ className?: string;
+}) {
+ const { loading, activeSubpage, isSidebarVisible } = useSettings();
+ const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
+
+ // Close mobile sidebar when a subpage is selected
+ const prevSubpage = usePrevious(activeSubpage);
+ useEffect(() => {
+ if (prevSubpage && activeSubpage !== prevSubpage) {
+ setMobileSidebarOpen(false);
+ }
+ }, [activeSubpage, prevSubpage]);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {/* ── Mobile backdrop ── */}
+ {isSidebarVisible && (
+
setMobileSidebarOpen(false)}
+ />
+ )}
+
+ {/* ── Sidebar (hidden when only one navigable item) ── */}
+ {isSidebarVisible && (
+
+ {/* Mobile close button */}
+
+ {title && (
+ {title}
+ )}
+ setMobileSidebarOpen(false)}
+ aria-label="Close menu"
+ data-testid="settings-mobile-close"
+ >
+
+
+
+
+ {/* Desktop title */}
+ {title && (
+
+
{title}
+
+ )}
+
+
+
+ )}
+
+ {/* ── Main content ── */}
+
+ {/* Mobile header with menu toggle (only when sidebar is visible) */}
+ {isSidebarVisible && (
+
+ setMobileSidebarOpen(true)}
+ aria-label="Open menu"
+ data-testid="settings-mobile-open"
+ >
+
+
+ {title && (
+ {title}
+ )}
+
+ )}
+
+
+
+
+ );
+}
+
+// ============================================
+// Utility: track previous value
+// ============================================
+
+function usePrevious
(value: T): T | undefined {
+ const [prev, setPrev] = useState(undefined);
+ const [current, setCurrent] = useState(value);
+
+ if (value !== current) {
+ setPrev(current);
+ setCurrent(value);
+ }
+
+ return prev;
+}
+
+// ============================================
+// Re-exports
+// ============================================
+
+export { useSettings } from './settings-context';
+export type { ApplyFiltersFunction } from './settings-context';
+export { formatSettingsData, extractValues } from './settings-formatter';
+export type { SettingsElement, SettingsProps, FieldComponentProps, SaveButtonRenderProps } from './settings-types';
diff --git a/src/components/settings/settings-content.tsx b/src/components/settings/settings-content.tsx
new file mode 100644
index 0000000..0e778d5
--- /dev/null
+++ b/src/components/settings/settings-content.tsx
@@ -0,0 +1,315 @@
+import type { SettingsElement as SettingsElementType } from './settings-types';
+import { useSettings } from './settings-context';
+import { FieldRenderer } from './field-renderer';
+import { cn } from '@/lib/utils';
+import { FileText } from 'lucide-react';
+
+// ============================================
+// Settings Content — renders heading, tabs, sections
+// ============================================
+
+export function SettingsContent({ className }: { className?: string }) {
+ const {
+ activePage,
+ activeSubpage,
+ getActiveContentSource,
+ getActiveTabs,
+ getActiveContent,
+ activeTab,
+ setActiveTab,
+ isPageDirty,
+ getPageValues,
+ onSave,
+ renderSaveButton,
+ } = useSettings();
+
+ const contentSource = getActiveContentSource();
+ const tabs = getActiveTabs();
+ const content = getActiveContent();
+
+ // Scope ID: subpage ID if a subpage is active, otherwise page ID
+ const scopeId = activeSubpage || activePage;
+ const dirty = isPageDirty(scopeId);
+
+ const handleSave = () => {
+ if (!onSave) return;
+ const scopeValues = getPageValues(scopeId);
+ onSave(scopeId, scopeValues);
+ };
+
+ // Determine whether to show a save area
+ const showSaveArea = Boolean(onSave);
+
+ if (!contentSource) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {/* Heading */}
+
+
+
+ {(contentSource.label || contentSource.title) && (
+
+ {contentSource.label || contentSource.title}
+
+ )}
+ {contentSource.description && (
+
+ {contentSource.description}
+
+ )}
+
+ {contentSource.doc_link && (
+
+
+
+ )}
+
+
+
+ {/* Tabs */}
+ {tabs.length > 0 && (
+
+
+ {tabs
+ .filter((tab) => tab.display !== false)
+ .map((tab) => {
+ const tabId = `settings-tab-${scopeId}-${tab.id}`;
+ const isSelected = activeTab === tab.id;
+ return (
+ setActiveTab(tab.id)}
+ data-testid={`settings-tab-${tab.id}`}
+ className={cn(
+ 'px-1 py-2.5 text-sm font-medium border-b-2 transition-colors',
+ isSelected
+ ? 'border-primary text-primary'
+ : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
+ )}
+ >
+ {tab.label || tab.title}
+
+ );
+ })}
+
+
+ )}
+
+ {/* Content — sections, fields, fieldgroups, subsections */}
+
0 ? `settings-tabpanel-${scopeId}` : undefined}
+ role={tabs.length > 0 ? 'tabpanel' : undefined}
+ aria-labelledby={tabs.length > 0 ? `settings-tab-${scopeId}-${activeTab}` : undefined}
+ className="p-6 space-y-6"
+ >
+ {content.map((item) => (
+
+ ))}
+
+
+
+ {/* Per-scope save button — sticky at the bottom */}
+ {showSaveArea && (
+
+ {renderSaveButton
+ ? renderSaveButton({ scopeId, dirty, onSave: handleSave })
+ : null}
+
+ )}
+
+ );
+}
+
+// ============================================
+// Content Block — dispatches top-level content by type
+// ============================================
+
+function ContentBlock({ element }: { element: SettingsElementType }) {
+ const { shouldDisplay } = useSettings();
+
+ if (!shouldDisplay(element)) {
+ return null;
+ }
+
+ switch (element.type) {
+ case 'section':
+ return ;
+
+ case 'subsection':
+ return (
+
+
+
+ );
+
+ case 'field':
+ // Direct field under a subpage (no section wrapper)
+ // Wrap in a minimal card for consistent styling
+ return (
+
+
+
+ );
+
+ case 'fieldgroup':
+ return (
+
+
+
+ );
+
+ default:
+ return null;
+ }
+}
+
+// ============================================
+// Settings Section
+// ============================================
+
+function SettingsSection({ section }: { section: SettingsElementType }) {
+ const { shouldDisplay } = useSettings();
+
+ if (!shouldDisplay(section)) {
+ return null;
+ }
+
+ const sectionLabel = section.label || section.title || '';
+ const hasHeading = Boolean(sectionLabel || section.description);
+
+ return (
+
+ {hasHeading && (
+
+
+ {sectionLabel && (
+
+ {sectionLabel}
+
+ )}
+ {section.description && (
+
+ {section.description}
+
+ )}
+
+ {section.doc_link && (
+
+
+ Doc
+
+ )}
+
+ )}
+
+
+ {section.children?.map((child) => (
+
+ ))}
+
+
+ );
+}
+
+// ============================================
+// Element Renderer — dispatches by type
+// ============================================
+
+function ElementRenderer({ element }: { element: SettingsElementType }) {
+ const { shouldDisplay } = useSettings();
+
+ if (!shouldDisplay(element)) {
+ return null;
+ }
+
+ switch (element.type) {
+ case 'section':
+ case 'subsection':
+ return ;
+
+ case 'field':
+ return ;
+
+ case 'fieldgroup':
+ return ;
+
+ default:
+ return null;
+ }
+}
+
+// ============================================
+// Sub-Section
+// ============================================
+
+function SettingsSubSection({ element }: { element: SettingsElementType }) {
+ const allChildrenAreFields = element.children?.every(
+ (c) => c.type === 'field' || c.type === 'fieldgroup'
+ );
+
+ const elementLabel = element.label || element.title || '';
+
+ return (
+
+ {(elementLabel || element.description) && (
+
+ {elementLabel && (
+
+ {elementLabel}
+
+ )}
+ {element.description && (
+
+ {element.description}
+
+ )}
+
+ )}
+
+ {element.children?.map((child) => (
+
+ ))}
+
+
+ );
+}
+
+// ============================================
+// Field Group
+// ============================================
+
+function SettingsFieldGroup({ element }: { element: SettingsElementType }) {
+ return (
+
+
+ {element.children?.map((child) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/settings/settings-context.tsx b/src/components/settings/settings-context.tsx
new file mode 100644
index 0000000..6836c77
--- /dev/null
+++ b/src/components/settings/settings-context.tsx
@@ -0,0 +1,529 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+ type ReactNode,
+} from 'react';
+import type { SaveButtonRenderProps, SettingsElement } from './settings-types';
+import {
+ evaluateDependencies,
+ extractValues,
+ formatSettingsData,
+ validateField,
+} from './settings-formatter';
+
+// ============================================
+// Context Value
+// ============================================
+
+/** Filter function signature compatible with @wordpress/hooks applyFilters */
+export type ApplyFiltersFunction = (hookName: string, value: any, ...args: any[]) => any;
+
+export interface SettingsContextValue {
+ /** Parsed hierarchical settings tree */
+ schema: SettingsElement[];
+ /** Flat map of field values keyed by dependency_key */
+ values: Record;
+ /** Validation errors keyed by dependency_key */
+ errors: Record;
+ /** Currently active page ID */
+ activePage: string;
+ /** Currently active subpage ID */
+ activeSubpage: string;
+ /** Currently active tab ID (if subpage has tab children) */
+ activeTab: string;
+ /** Whether the component is in a loading state */
+ loading: boolean;
+ /** Prefix for WordPress filter hook names */
+ hookPrefix: string;
+ /** Filter function for extensibility (e.g. @wordpress/hooks applyFilters) */
+ applyFilters: ApplyFiltersFunction;
+ /** Update a single field value */
+ updateValue: (key: string, value: any) => void;
+ /** Navigate to a page */
+ setActivePage: (pageId: string) => void;
+ /** Navigate to a subpage */
+ setActiveSubpage: (subpageId: string) => void;
+ /** Set active tab */
+ setActiveTab: (tabId: string) => void;
+ /** Check if a field should be displayed (evaluates dependencies) */
+ shouldDisplay: (element: SettingsElement) => boolean;
+ /** Get the currently active page element */
+ getActivePage: () => SettingsElement | undefined;
+ /** Get the currently active subpage element */
+ getActiveSubpage: () => SettingsElement | undefined;
+ /** Get the active content source element (subpage, or page when no subpages exist) */
+ getActiveContentSource: () => SettingsElement | undefined;
+ /** Get the active tab's children (sections) or the active content source's children */
+ getActiveContent: () => SettingsElement[];
+ /** Get tabs for the active content source (if any) */
+ getActiveTabs: () => SettingsElement[];
+ /** Whether the sidebar should be visible (false when there's only one navigable item) */
+ isSidebarVisible: boolean;
+ /** Check if any field on a specific page has been modified */
+ isPageDirty: (pageId: string) => boolean;
+ /** Get only the values that belong to a specific page */
+ getPageValues: (pageId: string) => Record;
+ /** Consumer-provided save handler (exposed so SettingsContent can call it) */
+ onSave?: (pageId: string, values: Record) => void | Promise;
+ /** Consumer-provided render function for the save button */
+ renderSaveButton?: (props: SaveButtonRenderProps) => React.ReactNode;
+}
+
+const SettingsContext = createContext(null);
+
+// ============================================
+// Provider
+// ============================================
+
+/** Default identity function when no applyFilters is provided */
+const defaultApplyFilters: ApplyFiltersFunction = (_hookName: string, value: any) => value;
+
+export interface SettingsProviderProps {
+ children: ReactNode;
+ schema: SettingsElement[];
+ values?: Record;
+ onChange?: (scopeId: string, key: string, value: any) => void;
+ onSave?: (scopeId: string, values: Record) => void | Promise;
+ renderSaveButton?: (props: SaveButtonRenderProps) => React.ReactNode;
+ loading?: boolean;
+ hookPrefix?: string;
+ /** Optional filter function for extensibility (e.g. @wordpress/hooks applyFilters) */
+ applyFilters?: ApplyFiltersFunction;
+}
+
+export function SettingsProvider({
+ children,
+ schema: rawSchema,
+ values: externalValues,
+ onChange,
+ onSave,
+ renderSaveButton,
+ loading = false,
+ hookPrefix = 'plugin_ui',
+ applyFilters: applyFiltersProp,
+}: SettingsProviderProps) {
+ // Format schema (handles both flat and hierarchical)
+ const schema = useMemo(() => formatSettingsData(rawSchema), [rawSchema]);
+
+ const filterFn = applyFiltersProp || defaultApplyFilters;
+
+ // Merge external values with defaults extracted from schema
+ const defaultValues = useMemo(() => extractValues(schema), [schema]);
+
+ // Compute initial merged values synchronously to avoid isDirty flash
+ const computeInitialMerged = () => ({ ...defaultValues, ...(externalValues || {}) });
+
+ const [internalValues, setInternalValues] = useState>(computeInitialMerged);
+ const [initialValues, setInitialValues] = useState>(computeInitialMerged);
+ const [errors, setErrors] = useState>({});
+
+ // Navigation state
+ const [activePage, setActivePage] = useState('');
+ const [activeSubpage, setActiveSubpage] = useState('');
+ const [activeTab, setActiveTab] = useState('');
+
+ // Build a memoized map of scopeId → [dependency_keys...] for per-subpage dirty tracking.
+ // The scope ID is the subpage ID when a subpage exists, otherwise the page ID itself.
+ const scopeFieldKeysMap = useMemo(() => {
+ const map = new Map();
+ const collectKeys = (elements: SettingsElement[]): string[] => {
+ const keys: string[] = [];
+ for (const el of elements) {
+ if (el.type === 'field' && el.dependency_key) {
+ keys.push(el.dependency_key);
+ }
+ if (el.children?.length) {
+ keys.push(...collectKeys(el.children));
+ }
+ }
+ return keys;
+ };
+ const walkSubpages = (elements: SettingsElement[]) => {
+ for (const el of elements) {
+ if (el.type === 'subpage') {
+ map.set(el.id, collectKeys(el.children || []));
+ }
+ // Recurse to find nested subpages
+ if (el.children?.length) {
+ walkSubpages(el.children);
+ }
+ }
+ };
+ for (const page of schema) {
+ const hasSubpages = page.children?.some((c) => c.type === 'subpage');
+ if (hasSubpages) {
+ walkSubpages(page.children || []);
+ } else {
+ // No subpages — scope to page itself
+ map.set(page.id, collectKeys(page.children || []));
+ }
+ }
+ return map;
+ }, [schema]);
+
+ // Reverse lookup: dependency_key → scopeId (subpage ID or page ID)
+ const keyToScopeMap = useMemo(() => {
+ const map = new Map();
+ for (const [scopeId, keys] of scopeFieldKeysMap.entries()) {
+ for (const key of keys) {
+ map.set(key, scopeId);
+ }
+ }
+ return map;
+ }, [scopeFieldKeysMap]);
+
+ // Sync internal values when external values change.
+ // NOTE: Do NOT reset initialValues here — that would break dirty tracking,
+ // because the consumer typically updates externalValues in their onChange handler
+ // (controlled component pattern). initialValues is captured once on mount
+ // and only reset after a save via resetPageDirty.
+ useEffect(() => {
+ const merged = { ...defaultValues, ...(externalValues || {}) };
+ setInternalValues(merged);
+ }, [defaultValues, externalValues]);
+
+ // Auto-select first page/subpage on schema load
+ useEffect(() => {
+ if (schema.length > 0 && !activePage) {
+ const firstPage = schema[0];
+ setActivePage(firstPage.id);
+
+ const firstSubpage = firstPage.children?.find((c) => c.type === 'subpage');
+ if (firstSubpage) {
+ setActiveSubpage(firstSubpage.id);
+ const firstTab = firstSubpage.children?.find((c) => c.type === 'tab');
+ if (firstTab) setActiveTab(firstTab.id);
+ } else {
+ // Page without subpages — check for direct tabs
+ setActiveSubpage('');
+ const firstTab = firstPage.children?.find((c) => c.type === 'tab');
+ setActiveTab(firstTab?.id || '');
+ }
+ }
+ }, [schema]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ // Merged values: external values take precedence, then internal, then defaults
+ const values = useMemo(
+ () => ({ ...defaultValues, ...internalValues, ...(externalValues || {}) }),
+ [defaultValues, internalValues, externalValues]
+ );
+
+ // Per-scope (subpage or page) dirty check
+ const isPageDirty = useCallback(
+ (scopeId: string): boolean => {
+ const keys = scopeFieldKeysMap.get(scopeId);
+ if (!keys) return false;
+ return keys.some((key) => values[key] !== initialValues[key]);
+ },
+ [scopeFieldKeysMap, values, initialValues]
+ );
+
+ // Per-scope values extraction
+ const getPageValues = useCallback(
+ (scopeId: string): Record => {
+ const keys = scopeFieldKeysMap.get(scopeId);
+ if (!keys) return {};
+ const scopeValues: Record = {};
+ for (const key of keys) {
+ if (key in values) {
+ scopeValues[key] = values[key];
+ }
+ }
+ return scopeValues;
+ },
+ [scopeFieldKeysMap, values]
+ );
+
+ // Reset per-scope dirty state after save
+ const resetPageDirty = useCallback(
+ (scopeId: string) => {
+ const keys = scopeFieldKeysMap.get(scopeId);
+ if (!keys) return;
+ setInitialValues((prev) => {
+ const next = { ...prev };
+ for (const key of keys) {
+ if (key in values) {
+ next[key] = values[key];
+ }
+ }
+ return next;
+ });
+ },
+ [scopeFieldKeysMap, values]
+ );
+
+ // Wrapped onSave that also resets dirty state for the page (only on success)
+ const handleOnSave = useCallback(
+ async (pageId: string, pageValues: Record) => {
+ if (!onSave) return;
+ await Promise.resolve(onSave(pageId, pageValues));
+ resetPageDirty(pageId);
+ },
+ [onSave, resetPageDirty]
+ );
+
+ // Update a field value
+ const updateValue = useCallback(
+ (key: string, value: any) => {
+ setInternalValues((prev) => ({ ...prev, [key]: value }));
+
+ // Find the element to validate
+ const findElement = (elements: SettingsElement[]): SettingsElement | undefined => {
+ for (const el of elements) {
+ if (el.dependency_key === key) return el;
+ if (el.children) {
+ const found = findElement(el.children);
+ if (found) return found;
+ }
+ }
+ return undefined;
+ };
+
+ const element = findElement(schema);
+ if (element) {
+ const error = validateField(element, value);
+ setErrors((prev) => {
+ const next = { ...prev };
+ if (error) {
+ next[key] = error;
+ } else {
+ delete next[key];
+ }
+ return next;
+ });
+ }
+
+ // Pass scopeId (subpage ID if exists, otherwise page ID) along with key and value
+ const scopeId = keyToScopeMap.get(key) || activeSubpage || activePage;
+ onChange?.(scopeId, key, value);
+ },
+ [schema, onChange, keyToScopeMap, activeSubpage, activePage]
+ );
+
+ // Dependency evaluation
+ const shouldDisplay = useCallback(
+ (element: SettingsElement): boolean => {
+ return evaluateDependencies(element, values);
+ },
+ [values]
+ );
+
+ // Navigation helpers
+ const handleSetActivePage = useCallback(
+ (pageId: string) => {
+ setActivePage(pageId);
+ const page = schema.find((p) => p.id === pageId);
+ if (page?.children?.length) {
+ const firstSubpage = page.children.find((c) => c.type === 'subpage');
+ if (firstSubpage) {
+ setActiveSubpage(firstSubpage.id);
+ const firstTab = firstSubpage.children?.find((c) => c.type === 'tab');
+ setActiveTab(firstTab?.id || '');
+ } else {
+ // Page without subpages — check for direct tabs
+ setActiveSubpage('');
+ const firstTab = page.children.find((c) => c.type === 'tab');
+ setActiveTab(firstTab?.id || '');
+ }
+ }
+ },
+ [schema]
+ );
+
+ const handleSetActiveSubpage = useCallback(
+ (subpageId: string) => {
+ setActiveSubpage(subpageId);
+
+ // Recursively find the subpage and its parent page
+ const findSubpageInPage = (
+ elements: SettingsElement[],
+ ): SettingsElement | undefined => {
+ for (const el of elements) {
+ if (el.id === subpageId && el.type === 'subpage') return el;
+ if (el.children) {
+ const found = findSubpageInPage(el.children);
+ if (found) return found;
+ }
+ }
+ return undefined;
+ };
+
+ for (const page of schema) {
+ const subpage = findSubpageInPage(page.children || []);
+ if (subpage) {
+ if (activePage !== page.id) {
+ setActivePage(page.id);
+ }
+ const firstTab = subpage.children?.find((c) => c.type === 'tab');
+ setActiveTab(firstTab?.id || '');
+ break;
+ }
+ }
+ },
+ [schema, activePage]
+ );
+
+ const getActivePage = useCallback(
+ () => schema.find((p) => p.id === activePage),
+ [schema, activePage]
+ );
+
+ const getActiveSubpage = useCallback(() => {
+ // Recursively search for the active subpage in the page tree
+ const findSubpage = (elements: SettingsElement[]): SettingsElement | undefined => {
+ for (const el of elements) {
+ if (el.id === activeSubpage && el.type === 'subpage') return el;
+ if (el.children) {
+ const found = findSubpage(el.children);
+ if (found) return found;
+ }
+ }
+ return undefined;
+ };
+
+ const page = getActivePage();
+ if (!page?.children) return undefined;
+ return findSubpage(page.children);
+ }, [getActivePage, activeSubpage]);
+
+ // The "content source" is the subpage when one is active,
+ // or the page itself when it has no subpages.
+ const getActiveContentSource = useCallback((): SettingsElement | undefined => {
+ const subpage = getActiveSubpage();
+ if (subpage) return subpage;
+
+ // No subpage — check if the active page has no subpages (direct content)
+ const page = getActivePage();
+ if (!page) return undefined;
+ const hasSubpages = page.children?.some((c) => c.type === 'subpage');
+ return hasSubpages ? undefined : page;
+ }, [getActiveSubpage, getActivePage]);
+
+ const getActiveTabs = useCallback(() => {
+ const source = getActiveContentSource();
+ if (!source?.children) return [];
+ return source.children.filter((c) => c.type === 'tab');
+ }, [getActiveContentSource]);
+
+ const getActiveContent = useCallback(() => {
+ const source = getActiveContentSource();
+ if (!source?.children) return [];
+
+ const tabs = source.children.filter((c) => c.type === 'tab');
+ if (tabs.length > 0 && activeTab) {
+ const tab = tabs.find((t) => t.id === activeTab);
+ return tab?.children || [];
+ }
+
+ // No tabs — return non-structural children
+ return source.children.filter((c) => c.type !== 'tab' && c.type !== 'subpage');
+ }, [getActiveContentSource, activeTab]);
+
+ // Sidebar visibility: count navigable leaf items. Hidden when <= 1.
+ const isSidebarVisible = useMemo(() => {
+ let count = 0;
+ const countLeafSubpages = (items: SettingsElement[]): void => {
+ for (const item of items) {
+ if (item.display === false) continue;
+ if (item.type !== 'subpage') continue;
+ const nested = (item.children || []).filter(
+ (c) => c.type === 'subpage' && c.display !== false
+ );
+ if (nested.length > 0) {
+ countLeafSubpages(nested);
+ } else {
+ count++;
+ }
+ }
+ };
+ for (const page of schema) {
+ if (page.display === false) continue;
+ const subpages = (page.children || []).filter(
+ (c) => c.type === 'subpage' && c.display !== false
+ );
+ if (subpages.length > 0) {
+ countLeafSubpages(subpages);
+ } else {
+ count++; // page without subpages counts as one navigable item
+ }
+ }
+ return count > 1;
+ }, [schema]);
+
+ const contextValue: SettingsContextValue = useMemo(
+ () => ({
+ schema,
+ values,
+ errors,
+ activePage,
+ activeSubpage,
+ activeTab,
+ loading,
+ hookPrefix,
+ applyFilters: filterFn,
+ updateValue,
+ setActivePage: handleSetActivePage,
+ setActiveSubpage: handleSetActiveSubpage,
+ setActiveTab,
+ shouldDisplay,
+ getActivePage,
+ getActiveSubpage,
+ getActiveContentSource,
+ getActiveContent,
+ getActiveTabs,
+ isSidebarVisible,
+ isPageDirty,
+ getPageValues,
+ onSave: handleOnSave,
+ renderSaveButton,
+ }),
+ [
+ schema,
+ values,
+ errors,
+ activePage,
+ activeSubpage,
+ activeTab,
+ loading,
+ hookPrefix,
+ filterFn,
+ updateValue,
+ handleSetActivePage,
+ handleSetActiveSubpage,
+ shouldDisplay,
+ getActivePage,
+ getActiveSubpage,
+ getActiveContentSource,
+ getActiveContent,
+ getActiveTabs,
+ isSidebarVisible,
+ isPageDirty,
+ getPageValues,
+ handleOnSave,
+ renderSaveButton,
+ ]
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+// ============================================
+// Hook
+// ============================================
+
+export function useSettings(): SettingsContextValue {
+ const ctx = useContext(SettingsContext);
+ if (!ctx) {
+ throw new Error('useSettings must be used within a component.');
+ }
+ return ctx;
+}
diff --git a/src/components/settings/settings-formatter.ts b/src/components/settings/settings-formatter.ts
new file mode 100644
index 0000000..02210b5
--- /dev/null
+++ b/src/components/settings/settings-formatter.ts
@@ -0,0 +1,447 @@
+import type { SettingsElement } from './settings-types';
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+/**
+ * Formats flat settings data into a hierarchical structure.
+ * Also accepts already-hierarchical data (passes through).
+ *
+ * ## Parent resolution strategy (bottom-up)
+ *
+ * Each flat element may carry one or more "parent pointer" fields:
+ * `field_group_id`, `subsection_id`, `section_id`, `tab_id`, `subpage_id`, `page_id`
+ *
+ * These pointers are **generic** — the referenced ID can be any element type.
+ * For example, `page_id` can point to a `page` **or** a `subpage`;
+ * `section_id` can point to a `section` **or** a `subsection`.
+ *
+ * The formatter resolves each element's direct parent by checking pointers
+ * from most specific to least specific. The first pointer whose value matches
+ * an existing element ID in the data becomes the parent.
+ *
+ * When multiple elements share the same ID (e.g. a subpage and a section both
+ * named "commission"), the resolver picks the element whose **type** is
+ * compatible with the pointer field being used.
+ *
+ * Priority order (most specific → least specific):
+ * field_group_id → subsection_id → section_id → tab_id → subpage_id → page_id
+ */
+export function formatSettingsData(data: SettingsElement[]): SettingsElement[] {
+ if (!Array.isArray(data) || data.length === 0) {
+ return [];
+ }
+
+ // Detect if data is already hierarchical (pages with children)
+ const isHierarchical = data.some(
+ (item) =>
+ item.type === 'page' &&
+ Array.isArray(item.children) &&
+ item.children.length > 0
+ );
+
+ if (isHierarchical) {
+ return data;
+ }
+
+ // ── Step 1: Enrich all items and build a multi-map (id → element[]) ─
+ //
+ // A multi-map is needed because the source data may contain duplicate IDs
+ // (e.g. a subpage and section sharing the same id). When resolving parents
+ // we pick the type-compatible element.
+ const idMultiMap = new Map();
+
+ const enrichedData: SettingsElement[] = data.map((item) => {
+ const enriched: SettingsElement = {
+ ...item,
+ children: [],
+ display: item.display !== undefined ? item.display : true,
+ hook_key: item.hook_key || '',
+ dependency_key: item.dependency_key || '',
+ validations: Array.isArray(item.validations) ? item.validations : [],
+ dependencies: Array.isArray(item.dependencies)
+ ? item.dependencies
+ : [],
+ };
+
+ const bucket = idMultiMap.get(enriched.id);
+ if (bucket) {
+ bucket.push(enriched);
+ } else {
+ idMultiMap.set(enriched.id, [enriched]);
+ }
+
+ return enriched;
+ });
+
+ // ── Step 2: Resolve each element's direct parent ────────────────────
+ //
+ // For each pointer field we define which element types are "compatible"
+ // parents. When the referenced ID maps to multiple elements the
+ // type-compatible one is preferred; otherwise the first match is used.
+ type PointerSpec = {
+ value: string | undefined | null;
+ compatibleTypes: string[];
+ };
+
+ const resolveParent = (item: SettingsElement): SettingsElement | null => {
+ const pointers: PointerSpec[] = [
+ {
+ value: item.field_group_id,
+ compatibleTypes: ['fieldgroup'],
+ },
+ {
+ value: item.subsection_id,
+ compatibleTypes: ['subsection', 'section'],
+ },
+ {
+ value: item.section_id,
+ compatibleTypes: ['section', 'subsection'],
+ },
+ {
+ value: item.tab_id,
+ compatibleTypes: ['tab'],
+ },
+ {
+ value: item.subpage_id,
+ compatibleTypes: ['subpage', 'page'],
+ },
+ {
+ value: item.page_id,
+ compatibleTypes: ['page', 'subpage'],
+ },
+ ];
+
+ for (const { value: ptr, compatibleTypes } of pointers) {
+ if (!ptr) continue;
+
+ const candidates = idMultiMap.get(ptr);
+ if (!candidates || candidates.length === 0) continue;
+
+ // Prefer a type-compatible candidate; fall back to first match
+ const match =
+ candidates.find((c) => compatibleTypes.includes(c.type)) ??
+ candidates[0];
+
+ // Don't attach an element to itself
+ if (match === item) {
+ // If there's another candidate, try that
+ const alt = candidates.find(
+ (c) => c !== item && compatibleTypes.includes(c.type)
+ );
+ if (alt) return alt;
+ continue;
+ }
+
+ return match;
+ }
+
+ return null;
+ };
+
+ // ── Step 3: Separate root pages and attach children to parents ──────
+ const roots: SettingsElement[] = [];
+
+ for (const element of enrichedData) {
+ if (element.type === 'page') {
+ // Initialize page-level defaults
+ element.label = element.label ?? element.title ?? '';
+ element.icon = element.icon || '';
+ element.tooltip = element.tooltip || '';
+ element.description = element.description || '';
+ element.hook_key =
+ element.hook_key || `settings_${element.id}`;
+ element.dependency_key = '';
+ roots.push(element);
+ continue;
+ }
+
+ const parent = resolveParent(element);
+ if (parent) {
+ parent.children!.push(element);
+ }
+ // Items with no resolvable parent are silently excluded (orphans).
+ }
+
+ // Sort root pages by priority
+ roots.sort((a, b) => (a.priority || 100) - (b.priority || 100));
+
+ // ── Step 4: Recursive enrichment ────────────────────────────────────
+ //
+ // Walk the tree top-down to compute hook_key, dependency_key, apply
+ // field-specific defaults, transform validations/dependencies, and
+ // sort children at each level.
+ const enrichNode = (parent: SettingsElement) => {
+ if (!parent.children || parent.children.length === 0) return;
+
+ parent.children.sort(
+ (a, b) => (a.priority || 100) - (b.priority || 100)
+ );
+
+ for (const child of parent.children) {
+ // Common defaults — resolve label from label ?? title ?? ''
+ child.label = child.label ?? child.title ?? '';
+ child.icon = child.icon || '';
+ child.tooltip = child.tooltip || '';
+ child.description = child.description || '';
+ child.display =
+ child.display !== undefined ? child.display : true;
+ child.hook_key = `${parent.hook_key}_${child.id}`;
+ child.dependency_key = [parent.dependency_key, child.id]
+ .filter(Boolean)
+ .join('.');
+
+ // ── Field-specific defaults ──
+ if (child.type === 'field') {
+ child.default =
+ child.default !== undefined ? child.default : '';
+ child.value =
+ child.value !== undefined
+ ? child.value
+ : child.default || '';
+ child.readonly = child.readonly || false;
+ child.disabled = child.disabled || false;
+ child.size = child.size || 20;
+ child.helper_text = child.helper_text || '';
+ child.postfix = child.postfix || '';
+ child.prefix = child.prefix || '';
+ child.image_url = child.image_url || '';
+ child.placeholder = child.placeholder || '';
+
+ if (child.variant === 'customize_radio') {
+ child.grid_config = child.grid_config || [];
+ }
+
+ if (child.options && Array.isArray(child.options)) {
+ const iconVariants = [
+ 'radio_capsule',
+ 'customize_radio',
+ ];
+ child.options = child.options.map((opt) => {
+ // Resolve label from label ?? title ?? ''
+ const resolvedLabel =
+ opt.label ?? opt.title ?? '';
+ const hasIcon = 'icon' in opt || 'image' in opt;
+ const shouldHaveIcon =
+ hasIcon ||
+ iconVariants.includes(child.variant || '');
+ if (shouldHaveIcon) {
+ return {
+ ...opt,
+ label: resolvedLabel,
+ icon: opt.icon || opt.image || '',
+ };
+ }
+ return { ...opt, label: resolvedLabel };
+ });
+ }
+ }
+
+ // ── Transform validations ──
+ if (child.validations) {
+ child.validations = child.validations.map((v) => ({
+ rules: v.rules || '',
+ message: v.message || '',
+ params: v.params || {},
+ self: child.dependency_key,
+ }));
+ }
+
+ // ── Transform dependencies ──
+ if (child.dependencies) {
+ child.dependencies = child.dependencies.map((d) => ({
+ ...d,
+ self: child.dependency_key,
+ to_self: d.to_self ?? true,
+ attribute: d.attribute || 'display',
+ effect: d.effect || 'show',
+ comparison: d.comparison || '==',
+ }));
+ }
+
+ // Ensure children array exists
+ if (!child.children) {
+ child.children = [];
+ }
+
+ enrichNode(child);
+ }
+ };
+
+ roots.forEach((root) => enrichNode(root));
+
+ return roots;
+}
+
+/**
+ * Extracts a flat key-value map of field values from a hierarchical schema.
+ */
+export function extractValues(
+ schema: SettingsElement[]
+): Record {
+ const values: Record = {};
+
+ const walk = (elements: SettingsElement[]) => {
+ for (const el of elements) {
+ if (el.type === 'field' && el.dependency_key) {
+ values[el.dependency_key] = el.value;
+ }
+ if (el.children && el.children.length > 0) {
+ walk(el.children);
+ }
+ }
+ };
+
+ walk(schema);
+ return values;
+}
+
+/**
+ * Evaluates whether a field should be displayed based on its dependencies.
+ */
+export function evaluateDependencies(
+ element: SettingsElement,
+ values: Record
+): boolean {
+ if (!element.dependencies || element.dependencies.length === 0) {
+ return element.display !== false;
+ }
+
+ return element.dependencies.every((dep) => {
+ if (!dep.key) return true;
+
+ const currentValue = values[dep.key];
+ const comparison = dep.comparison || '==';
+ const expectedValue = dep.value;
+
+ switch (comparison) {
+ case '==':
+ return currentValue == expectedValue; // eslint-disable-line eqeqeq
+ case '!=':
+ return currentValue != expectedValue; // eslint-disable-line eqeqeq
+ case '===':
+ return currentValue === expectedValue;
+ case '!==':
+ return currentValue !== expectedValue;
+ case 'in':
+ return (
+ Array.isArray(expectedValue) &&
+ expectedValue.includes(currentValue)
+ );
+ case 'not_in':
+ return (
+ Array.isArray(expectedValue) &&
+ !expectedValue.includes(currentValue)
+ );
+ default:
+ return true;
+ }
+ });
+}
+
+/**
+ * Validates a field value against its validation rules.
+ * Supports pipe-delimited rules: "not_empty|min_value|max_value"
+ * Returns an error message string or null if valid.
+ */
+export function validateField(
+ element: SettingsElement,
+ value: any
+): string | null {
+ if (!element.validations || element.validations.length === 0) {
+ return null;
+ }
+
+ for (const validation of element.validations) {
+ // Handle pipe-delimited rules
+ const rules = validation.rules.split('|');
+
+ for (const rule of rules) {
+ const params = (validation.params as any) || {};
+
+ switch (rule) {
+ case 'not_in': {
+ const forbidden = params.values || [];
+ if (
+ Array.isArray(forbidden) &&
+ forbidden.includes(value)
+ ) {
+ return (
+ validation.message.replace(
+ '%s',
+ String(value)
+ ) ||
+ `The value "${value}" is not allowed.`
+ );
+ }
+ break;
+ }
+ case 'required':
+ case 'not_empty': {
+ if (
+ value === undefined ||
+ value === null ||
+ value === ''
+ ) {
+ return (
+ validation.message ||
+ 'This field is required.'
+ );
+ }
+ if (
+ typeof value === 'string' &&
+ value.trim() === ''
+ ) {
+ return (
+ validation.message ||
+ 'This field cannot be empty.'
+ );
+ }
+ break;
+ }
+ case 'min':
+ case 'min_value': {
+ let min: number | undefined;
+
+ if ('min' in params) min = Number(params.min);
+ else if ('value' in params)
+ min = Number(params.value);
+
+ if (
+ min !== undefined &&
+ !isNaN(min) &&
+ Number(value) < min
+ ) {
+ return (
+ validation.message ||
+ `Value must be at least ${min}.`
+ );
+ }
+ break;
+ }
+ case 'max':
+ case 'max_value': {
+ let max: number | undefined;
+
+ if ('max' in params) max = Number(params.max);
+ else if ('value' in params)
+ max = Number(params.value);
+
+ if (
+ max !== undefined &&
+ !isNaN(max) &&
+ Number(value) > max
+ ) {
+ return (
+ validation.message ||
+ `Value must be at most ${max}.`
+ );
+ }
+ break;
+ }
+ default:
+ break;
+ }
+ }
+ }
+
+ return null;
+}
diff --git a/src/components/settings/settings-sidebar.tsx b/src/components/settings/settings-sidebar.tsx
new file mode 100644
index 0000000..c5cdf25
--- /dev/null
+++ b/src/components/settings/settings-sidebar.tsx
@@ -0,0 +1,193 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { useMemo, useState } from 'react';
+import { useSettings } from './settings-context';
+import {
+ LayoutMenu,
+ type LayoutMenuItemData,
+} from '../ui/layout-menu';
+import type { SettingsElement } from './settings-types';
+import * as LucideIcons from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { Input } from '../ui/input';
+import { Search } from 'lucide-react';
+
+// ============================================
+// Settings Sidebar — deep-searchable menu
+// ============================================
+
+/**
+ * Resolve an icon name to a Lucide icon component.
+ */
+function getIcon(iconName?: string): React.ReactNode {
+ if (!iconName) return null;
+
+ const IconComponent = (LucideIcons as any)[iconName];
+ if (IconComponent) {
+ return ;
+ }
+
+ return null;
+}
+
+/**
+ * Collect all searchable text from a schema subtree.
+ * Includes labels, titles, descriptions, option labels, placeholder text.
+ */
+function collectSearchableText(element: SettingsElement): string {
+ const texts: string[] = [];
+ const walk = (el: SettingsElement) => {
+ if (el.label) texts.push(el.label);
+ if (el.title) texts.push(el.title);
+ if (el.description) texts.push(el.description);
+ if (el.placeholder) texts.push(String(el.placeholder));
+ if (el.options) {
+ for (const opt of el.options) {
+ if (opt.label) texts.push(opt.label);
+ if (opt.title) texts.push(opt.title);
+ if (opt.description) texts.push(opt.description);
+ }
+ }
+ if (el.children) el.children.forEach(walk);
+ };
+ walk(element);
+ return texts.join(' ').toLowerCase();
+}
+
+export function SettingsSidebar({ className }: { className?: string }) {
+ const {
+ schema,
+ activePage,
+ activeSubpage,
+ setActivePage,
+ setActiveSubpage,
+ } = useSettings();
+
+ const [search, setSearch] = useState('');
+
+ // Build menu items + a search index (id → all searchable text in subtree)
+ const { items, searchIndex } = useMemo(() => {
+ const searchIdx = new Map();
+
+ const mapSubpageToItem = (element: SettingsElement): LayoutMenuItemData => {
+ const nestedSubpages = (element.children || [])
+ .filter((child) => child.type === 'subpage' && child.display !== false)
+ .map(mapSubpageToItem);
+
+ searchIdx.set(element.id, collectSearchableText(element));
+
+ return {
+ id: element.id,
+ label: element.label || element.title || element.id,
+ icon: getIcon(element.icon),
+ testId: `settings-menu-${element.id}`,
+ children: nestedSubpages.length > 0 ? nestedSubpages : undefined,
+ };
+ };
+
+ const menuItems: LayoutMenuItemData[] = schema
+ .filter((page) => page.display !== false)
+ .map((page) => {
+ const subpageItems = (page.children || [])
+ .filter((child) => child.type === 'subpage' && child.display !== false)
+ .map(mapSubpageToItem);
+
+ searchIdx.set(page.id, collectSearchableText(page));
+
+ if (subpageItems.length > 0) {
+ // Page WITH subpages → parent item with expandable children
+ return {
+ id: page.id,
+ label: page.label || page.title || page.id,
+ icon: getIcon(page.icon),
+ testId: `settings-menu-${page.id}`,
+ children: subpageItems,
+ };
+ }
+ // Page WITHOUT subpages → clickable leaf item
+ return {
+ id: page.id,
+ label: page.label || page.title || page.id,
+ icon: getIcon(page.icon),
+ testId: `settings-menu-${page.id}`,
+ };
+ });
+
+ return { items: menuItems, searchIndex: searchIdx };
+ }, [schema]);
+
+ // Deep-search filter: matches against full subtree content, not just labels
+ const filteredItems = useMemo(() => {
+ const q = search.trim().toLowerCase();
+ if (!q) return items;
+
+ const matchesDeep = (item: LayoutMenuItemData): boolean => {
+ const text = searchIndex.get(item.id) || '';
+ if (text.includes(q)) return true;
+ return item.children?.some(matchesDeep) || false;
+ };
+
+ const filterTree = (menuItems: LayoutMenuItemData[]): LayoutMenuItemData[] => {
+ return menuItems
+ .map((item) => {
+ if (!item.children) {
+ return matchesDeep(item) ? item : null;
+ }
+ // Parent: filter children first
+ const filteredChildren = filterTree(item.children);
+ if (filteredChildren.length > 0) {
+ return { ...item, children: filteredChildren };
+ }
+ // Keep parent if its own subtree text matches
+ return matchesDeep(item) ? item : null;
+ })
+ .filter(Boolean) as LayoutMenuItemData[];
+ };
+
+ return filterTree(items);
+ }, [items, search, searchIndex]);
+
+ // Active item: subpage if exists, otherwise page (for pages without subpages)
+ const activeItemId = activeSubpage || activePage;
+
+ return (
+
+ {/* Deep-search input */}
+
+
+
+ setSearch(e.target.value)}
+ className="h-8 pl-8"
+ aria-label="Search settings"
+ data-testid="settings-search"
+ />
+
+
+
+
{
+ const isPage = schema.some((p) => p.id === item.id);
+ if (isPage) {
+ const page = schema.find((p) => p.id === item.id);
+ const hasSubpages = page?.children?.some(
+ (c) => c.type === 'subpage' && c.display !== false
+ );
+ if (!hasSubpages) {
+ // Page without subpages — navigate directly
+ setActivePage(item.id);
+ }
+ // Pages WITH subpages: LayoutMenu handles expand/collapse
+ } else {
+ // Subpage — navigate
+ setActiveSubpage(item.id);
+ }
+ }}
+ />
+
+ );
+}
diff --git a/src/components/settings/settings-types.ts b/src/components/settings/settings-types.ts
new file mode 100644
index 0000000..4c28cae
--- /dev/null
+++ b/src/components/settings/settings-types.ts
@@ -0,0 +1,175 @@
+// ============================================
+// Settings Element Types
+// ============================================
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+export type SettingsValidation = {
+ rules: string;
+ message: string;
+ params?: Record | any[];
+ self?: string;
+};
+
+export type SettingsElementDependency = {
+ key?: string;
+ value?: any;
+ currentValue?: any;
+ to_self?: boolean;
+ self?: string;
+ attribute?: string;
+ effect?: string;
+ comparison?: string;
+};
+
+export type SettingsElementOption = {
+ /** Primary display text (preferred over `title`). */
+ label?: string;
+ /** @deprecated Use `label` instead. Kept as fallback for backward compatibility. */
+ title?: string;
+ value: string | number;
+ description?: string;
+ icon?: string;
+ image?: string;
+ preview?: boolean;
+};
+
+export type SettingsElement = {
+ id: string;
+ type: 'page' | 'subpage' | 'tab' | 'section' | 'subsection' | 'field' | 'fieldgroup' | string;
+ variant?: string;
+ icon?: string;
+ /** Primary display text (preferred over `title`). */
+ label?: string;
+ /** @deprecated Use `label` instead. Kept as fallback for backward compatibility. */
+ title?: string;
+ description?: string;
+ tooltip?: string;
+ display?: boolean;
+ hook_key?: string;
+ dependency_key?: string;
+ children?: SettingsElement[];
+
+ // Field-specific
+ value?: string | number | boolean | Array | Record;
+ default?: string | number | boolean | Array;
+ options?: SettingsElementOption[];
+ readonly?: boolean;
+ disabled?: boolean;
+ placeholder?: string | number;
+ min?: number;
+ max?: number;
+ increment?: number;
+ size?: number;
+ helper_text?: string;
+ prefix?: string;
+ postfix?: string;
+ suffix?: string;
+ image_url?: string;
+ doc_link?: string;
+ css_class?: string;
+ wrapper_class?: string;
+ content_class?: string;
+ divider?: boolean;
+
+ // Switch-specific
+ enable_state?: { value: string | number; title: string };
+ disable_state?: { value: string | number; title: string };
+ switcher_type?: string | null;
+ should_confirm?: boolean;
+ confirm_modal?: Record;
+
+ // Radio-specific
+ radio_variant?: 'simple' | 'card' | 'template' | string;
+ grid_config?: any[];
+
+ // HTML-specific
+ html_content?: string;
+ escape_html?: boolean;
+
+ // Validation & Dependencies
+ validations?: SettingsValidation[];
+ dependencies?: SettingsElementDependency[];
+
+ // Flat data pointers (used by formatter)
+ // These are generic parent pointers — each can reference any ancestor type.
+ // The formatter resolves the actual parent by looking up the element type via ID.
+ // e.g. `page_id` can point to a page OR a subpage;
+ // `section_id` can point to a section OR a subsection.
+ page_id?: string;
+ subpage_id?: string;
+ tab_id?: string;
+ section_id?: string;
+ subsection_id?: string;
+ field_group_id?: string;
+ priority?: number;
+
+ // Validation error (runtime)
+ validationError?: string;
+
+ // Allow additional properties
+ [key: string]: any;
+};
+
+// ============================================
+// Component Props
+// ============================================
+
+/** Props passed to the renderSaveButton render-prop. */
+export interface SaveButtonRenderProps {
+ /** The active scope ID (subpage ID if active, otherwise page ID). */
+ scopeId: string;
+ /** Whether any field in the current scope has been modified. */
+ dirty: boolean;
+ /** Call this to trigger save — invokes `onSave(scopeId, scopeValues)`. */
+ onSave: () => void;
+}
+
+export interface SettingsProps {
+ /** Settings schema — JSON array (flat or hierarchical) */
+ schema: SettingsElement[];
+ /** Current values, keyed by dependency_key */
+ values?: Record;
+ /** Called when a field value changes. Receives the scope ID (subpage/page), field key, and new value. */
+ onChange?: (scopeId: string, key: string, value: any) => void;
+ /** Called when the save button is clicked. Receives the scope ID and that scope's values only. */
+ onSave?: (scopeId: string, values: Record) => void;
+ /**
+ * Custom render function for the save button area.
+ * Use this to provide your own translated save button.
+ *
+ * @example
+ * ```tsx
+ * import { __ } from '@wordpress/i18n';
+ *
+ * renderSaveButton={({ dirty, onSave }) => (
+ *
+ * {__('Save Changes', 'my-plugin')}
+ *
+ * )}
+ * ```
+ *
+ * If not provided but `onSave` is set, a default icon-only save button is rendered.
+ */
+ renderSaveButton?: (props: SaveButtonRenderProps) => React.ReactNode;
+ /** Whether settings are loading */
+ loading?: boolean;
+ /** Title displayed above the settings */
+ title?: string;
+ /** Prefix for WordPress filter hook names (default: "plugin_ui") */
+ hookPrefix?: string;
+ /** Additional class name for the root element */
+ className?: string;
+ /**
+ * Optional filter function for field extensibility.
+ * Pass @wordpress/hooks `applyFilters` to enable consumer plugins
+ * to inject/override field types via filter hooks.
+ * Signature: (hookName: string, value: any, ...args: any[]) => any
+ * If not provided, fields render without filtering.
+ */
+ applyFilters?: (hookName: string, value: any, ...args: any[]) => any;
+}
+
+export interface FieldComponentProps {
+ element: SettingsElement;
+ onChange: (key: string, value: any) => void;
+}
diff --git a/src/components/ui/layout-menu.tsx b/src/components/ui/layout-menu.tsx
index 067dcb1..81f0e14 100644
--- a/src/components/ui/layout-menu.tsx
+++ b/src/components/ui/layout-menu.tsx
@@ -3,6 +3,7 @@ import { ChevronDown, ChevronRight, Search } from "lucide-react";
import {
forwardRef,
useCallback,
+ useEffect,
useMemo,
useRef,
useState,
@@ -28,6 +29,8 @@ export interface LayoutMenuItemData {
disabled?: boolean;
/** Custom className for the item row */
className?: string;
+ /** Test ID for e2e selectors — rendered as `data-testid` on the interactive element */
+ testId?: string;
}
export interface LayoutMenuGroupData {
@@ -448,6 +451,21 @@ interface LayoutMenuItemNodeProps {
renderItem?: (item: LayoutMenuItemData, depth: number) => ReactNode;
}
+/**
+ * Check if any descendant of an item matches the active ID.
+ * Used to auto-expand parent items that contain the active selection.
+ */
+function hasActiveDescendant(
+ item: LayoutMenuItemData,
+ activeId: string | null | undefined
+): boolean {
+ if (!activeId || !item.children) return false;
+ return item.children.some(
+ (child) =>
+ child.id === activeId || hasActiveDescendant(child, activeId)
+ );
+}
+
function LayoutMenuItemNode({
item,
depth,
@@ -457,9 +475,18 @@ function LayoutMenuItemNode({
onItemClick,
renderItem,
}: LayoutMenuItemNodeProps) {
- const [open, setOpen] = useState(false);
+ const containsActive = useMemo(
+ () => hasActiveDescendant(item, activeItemId),
+ [item, activeItemId]
+ );
+ const [open, setOpen] = useState(containsActive);
const hasChildren = item.children && item.children.length > 0;
+ // Auto-expand when a descendant becomes active (e.g. programmatic navigation)
+ useEffect(() => {
+ if (containsActive) setOpen(true);
+ }, [containsActive]);
+
const handleToggle = useCallback(() => {
setOpen((o) => !o);
}, []);
@@ -504,6 +531,7 @@ function LayoutMenuItemNode({
diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx
index c339fd4..f3f0266 100644
--- a/src/components/ui/popover.tsx
+++ b/src/components/ui/popover.tsx
@@ -84,11 +84,28 @@ function PopoverDescription({
)
}
+function PopoverClose({ ...props }: PopoverPrimitive.Close.Props) {
+ return ;
+}
+
+function PopoverArrow({ className, ...props }: PopoverPrimitive.Arrow.Props) {
+ return (
+
+ );
+}
+
export {
Popover,
+ PopoverArrow,
+ PopoverClose,
PopoverContent,
PopoverDescription,
PopoverHeader,
+ PopoverPortal,
PopoverTitle,
PopoverTrigger,
}
diff --git a/src/components/wordpress/dataviews.tsx b/src/components/wordpress/dataviews.tsx
index d700068..29d6fda 100644
--- a/src/components/wordpress/dataviews.tsx
+++ b/src/components/wordpress/dataviews.tsx
@@ -381,7 +381,6 @@ export function DataViews- (props: DataViewsProps
- ) {
filter,
tabs,
search,
- searchLabel,
searchPlaceholder = __('Search', 'default'),
...dataViewsTableProps
} = props;
diff --git a/src/index.ts b/src/index.ts
index 72fb5aa..f715ca7 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -252,6 +252,18 @@ export { TopBar, type TopBarProps } from './components/top-bar'
export { CrownIcon, type CrownIconProps } from './components/crown-icon'
export { ButtonToggleGroup, type ButtonToggleGroupProps, type ButtonToggleGroupItem } from './components/button-toggle-group'
+// Settings (schema-driven settings page)
+export {
+ Settings,
+ useSettings,
+ formatSettingsData,
+ extractValues,
+ type ApplyFiltersFunction,
+ type SettingsProps,
+ type SettingsElement,
+ type FieldComponentProps,
+} from './components/settings';
+
// ============================================
// Theme Presets
// ============================================