import { useCallback, useEffect, useRef, useState, type ComponentType, type CSSProperties, type KeyboardEvent, type ReactNode, } from "react"; export const ADMIN_SHELL_CONTRACT = "b3-wvs-admin-shell-v1"; export const MAX_SETTINGS_SEARCH_QUERY_LENGTH = 120; /** Matches the index.css breakpoint where the sidebar stacks above main. */ export const STACKED_SHELL_QUERY = "(max-width: 1024px)"; /** * On the stacked (phone/tablet) layout the main panel sits below the whole * nav and the live preview, so choosing a section would otherwise change * content far off screen. Bring the panel's top to just under the sticky * topbar and move focus there so VoiceOver continues from the new section. */ function revealStackedMainContent( main: HTMLElement | null, topbar: HTMLElement | null, ): void { if ( !main || typeof window.matchMedia !== "function" || !window.matchMedia(STACKED_SHELL_QUERY).matches ) { return; } let stickyOffset = 0; if (topbar) { const stickyTop = parseFloat(window.getComputedStyle(topbar).top); stickyOffset = (Number.isFinite(stickyTop) ? stickyTop : 0) + topbar.offsetHeight; } const target = main.getBoundingClientRect().top + window.scrollY - stickyOffset - 12; const reduceMotion = window.matchMedia( "(prefers-reduced-motion: reduce)", ).matches; window.scrollTo({ top: Math.max(0, target), behavior: reduceMotion ? "auto" : "smooth", }); main.focus({ preventScroll: true }); } export type AdminShellNavIconProps = { className?: string }; export type AdminShellNavItem = { id: SectionId; label: string; Icon: ComponentType; }; export type AdminShellAction = { id: string; label: string; onClick: () => void; disabled?: boolean; variant: "primary" | "secondary"; }; export type AdminShellSearch = { value: string; label: string; placeholder: string; help: string; clearLabel: string; clearText: string; onChange: (value: string) => void; onSubmit?: () => void; onFocusResults?: () => void; }; export type AdminShellPreview = { title: string; meta: ReactNode; content: ReactNode; footer: string; }; export type AdminShellToast = { show: boolean; type: "success" | "error" | "info"; message: string; }; export interface AdminShellProps { adminColor: string; title: string; brandBadge?: ReactNode; topbarActions?: AdminShellAction[]; search: AdminShellSearch; navigationLabel: string; navItems: Array>; activeSection: SectionId; onSelectSection: (section: SectionId) => void; preview: AdminShellPreview; sidebarFooter?: ReactNode; mainContent: ReactNode; mainActions?: AdminShellAction[]; toast: AdminShellToast; } function renderActions(actions: AdminShellAction[] | undefined) { if (!actions?.length) return null; return actions.map((action) => ( )); } export function AdminShell({ adminColor, title, brandBadge, topbarActions, search, navigationLabel, navItems, activeSection, onSelectSection, preview, sidebarFooter, mainContent, mainActions, toast, }: AdminShellProps) { const navRef = useRef(null); const topbarRef = useRef(null); const mainRef = useRef(null); const isSearching = search.value.trim().length > 0; const navButtonRefs = useRef>({}); const [navSliderStyle, setNavSliderStyle] = useState({ height: 0, width: 0, transform: "translate(0,0)", opacity: 0, }); const updateNavSlider = useCallback(() => { const activeElement = navButtonRefs.current[activeSection]; const container = navRef.current; if (!activeElement || !container) return; try { const activeRect = activeElement.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); if (activeRect.height === 0 || containerRect.height === 0) return; setNavSliderStyle({ height: activeRect.height, width: activeRect.width, transform: `translate(${activeRect.left - containerRect.left}px,${activeRect.top - containerRect.top}px)`, opacity: 1, }); } catch { // Ignore measurements while the admin viewport is changing. } }, [activeSection]); useEffect(() => { requestAnimationFrame(updateNavSlider); }, [updateNavSlider]); useEffect(() => { let resizeTimer: number; const handleResize = () => { window.clearTimeout(resizeTimer); resizeTimer = window.setTimeout( () => requestAnimationFrame(updateNavSlider), 50, ); }; window.addEventListener("resize", handleResize, { passive: true }); const fallbackTimer = window.setTimeout(updateNavSlider, 150); return () => { window.removeEventListener("resize", handleResize); window.clearTimeout(fallbackTimer); window.clearTimeout(resizeTimer); }; }, [updateNavSlider]); function handleSearchKeyDown(event: KeyboardEvent) { if (event.key === "Escape" && search.value) { event.preventDefault(); search.onChange(""); return; } if (event.key === "Enter" && search.onSubmit) { event.preventDefault(); search.onSubmit(); return; } if (event.key === "ArrowDown" && search.value && search.onFocusResults) { event.preventDefault(); search.onFocusResults(); } } return (

{title}

{brandBadge}
{renderActions(topbarActions)}
{mainContent} {mainActions?.length ? (
{renderActions(mainActions)}
) : null}
{/* Presentational only. The owning app announces through the persistent live regions in `liveAnnouncer`, which also lets an error toast be announced assertively — this node was hardcoded to `polite`. It stays mounted after it fades out (opacity, not display), so exposing it would leave a stale message in the a11y tree. */}
); } export function BookIcon({ className = "bwvs:w-4 bwvs:h-4", }: AdminShellNavIconProps) { return ( ); } export function GearIcon({ className = "bwvs:w-4 bwvs:h-4", }: AdminShellNavIconProps) { return ( ); } /** * Animated B3 Swatches logo mark: the four tiles of the Classic icon, in its * colors, with a clockwise pulse. Mirrors the geometry and SMIL animation in * assets/b3-swatches-settings-logo.svg used by the WP menu. */ export function BrandLogoMark({ className = "bwvs:w-4 bwvs:h-4", }: AdminShellNavIconProps) { const tiles = [ { x: 0, y: 0, fill: "#ffffff", opacity: 0.62 }, { x: 88, y: 0, fill: "#ffffff", opacity: 1 }, { x: 88, y: 88, fill: "#ffffff", opacity: 0.62 }, { x: 0, y: 88, fill: "#0b1220", opacity: 0.55 }, ]; return ( ); } export function SwatchIcon({ className = "bwvs:w-4 bwvs:h-4", }: AdminShellNavIconProps) { return ( ); } export function TypeIcon({ className = "bwvs:w-4 bwvs:h-4", }: AdminShellNavIconProps) { return ( ); } export function TooltipIcon({ className = "bwvs:w-4 bwvs:h-4", }: AdminShellNavIconProps) { return ( ); } export function QuantityIcon({ className = "bwvs:w-4 bwvs:h-4", }: AdminShellNavIconProps) { return ( ); } export function WrenchIcon({ className = "bwvs:w-4 bwvs:h-4", }: AdminShellNavIconProps) { return ( ); } export function MotionIcon({ className = "bwvs:w-4 bwvs:h-4", }: AdminShellNavIconProps) { return ( ); }