diff --git a/src/components/settings/Settings.stories.tsx b/src/components/settings/Settings.stories.tsx index ca970fa..79b2714 100644 --- a/src/components/settings/Settings.stories.tsx +++ b/src/components/settings/Settings.stories.tsx @@ -42,7 +42,7 @@ function EventLog({ entries }: { entries: LogEntry[] }) { if (entries.length === 0) return null; return ( -
+
Event Log
diff --git a/src/components/settings/fields.tsx b/src/components/settings/fields.tsx index 0bf3011..6a963e4 100644 --- a/src/components/settings/fields.tsx +++ b/src/components/settings/fields.tsx @@ -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) // ============================================ @@ -141,12 +159,12 @@ function FieldLabel({ element }: { element: SettingsElement }) { )}
- + {displayLabel} {IconComponent && ( - + )} {element.tooltip && ( @@ -154,7 +172,7 @@ function FieldLabel({ element }: { element: SettingsElement }) { @@ -165,7 +183,7 @@ function FieldLabel({ element }: { element: SettingsElement }) { )}
{element.description && ( -
+
{element.description}
)} @@ -325,7 +343,7 @@ export function GoogleAnalyticsField({ element, onChange, ...rest }: FieldCompon
: null; }; const selectedIcon = (selectedOption as { icon?: string } | undefined)?.icon; + const placeholder = element.placeholder + ? String(element.placeholder) + : "Select..."; return ( @@ -437,14 +458,17 @@ export function SelectField({ element, onChange, ...rest }: FieldComponentProps) disabled={element.disabled} > - - {renderOptionIcon(selectedIcon)} - {selectedLabel} - + {/* 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 ? ( + + {renderOptionIcon(selectedIcon)} + {selectedLabel} + + ) : ( + + )} {element.options?.map((option) => ( @@ -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 @@ -669,21 +696,29 @@ export function DangerSwitchField({ element, onChange }: FieldComponentProps) { const ackId = `${element.id}-confirm-ack`; return ( -
-
+ // 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. +
+ {/* 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. */} +
{displayLabel && ( - {displayLabel} + {displayLabel} )} - {IconComponent && } + {IconComponent && }
{element.description && ( -
+
{element.description}
)}
-
+
@@ -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 ? (
@@ -893,7 +928,7 @@ export function PreviewMulticheckField({ element, onChange, ...rest }: FieldComp {element.image_url && (
-
+
@@ -910,7 +945,7 @@ export function PreviewMulticheckField({ element, onChange, ...rest }: FieldComp export function LabelField({ element, ...rest }: FieldComponentProps) { return (
@@ -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 + {children} + + + + ); +} + interface RichTextEditorContentAction { show?: boolean; showContent?: boolean; @@ -140,38 +167,26 @@ function RichTextEditor({ )} - + - + {variant === "full" && ( - + )}
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({ )} >
- + {/* No connector rule down the left: the indent plus the active pill + already read as nesting. `translate-x` reset too — it only existed + to sit the items off that rule. */} + {item.children!.map((child) => (