import React, { useEffect, useLayoutEffect, useRef, useState } from "react"; import { isRtlContext } from "../../frontend/shared/radiogroup"; import { resolvePanelTabKey, tabControlId, tabPanelId, type PanelTabDescriptor, } from "./panelTabsUtils"; /** * Animated sub-tab strip that switches which settings panel is visible. * * This is the WAI-ARIA Tabs pattern — `role="tablist"` / `role="tab"` / * `role="tabpanel"` — not a radiogroup. The distinction matters: a radiogroup * announces "choose one of N settings", but these controls choose which * *region of the page* is shown. Screen-reader users get "tab, 2 of 3" plus a * navigable panel relationship instead of a phantom setting. * * Behavior: one tab stop for the strip (roving tabindex), horizontal Left/Right * with wrapping (RTL-aware), Home/End to jump. ArrowUp/ArrowDown are left alone * so they keep scrolling the panel. Activation follows focus (the panel swaps * immediately), which APG permits because panels render instantly here. * * Panels are mounted only while active, so `aria-controls` is emitted only for * the active tab — pointing at an unmounted id would be a dangling reference. * Wrap each panel's existing element with `tabPanelProps(baseId, tabId)`. * * Free-side component: imports nothing premium, consumed by both admin bundles. */ export interface PanelTabsProps { /** Stable id prefix; must be unique per tablist on the page. */ baseId: string; tabs: PanelTabDescriptor[]; activeTab: string; onChange: (id: string) => void; /** Accessible name for the tablist — always supplied by the caller. */ ariaLabel: string; className?: string; } export const PanelTabs: React.FC = ({ baseId, tabs, activeTab, onChange, ariaLabel, className = "", }) => { const [sliderStyle, setSliderStyle] = useState({ width: 0, transform: "translateX(0)", opacity: 0, }); const containerRef = useRef(null); const tabsRef = useRef<{ [key: string]: HTMLButtonElement | null }>({}); const updateSlider = () => { const activeElement = tabsRef.current[activeTab]; const container = containerRef.current; // Validate that we have valid elements and that activeTab exists in our tabs if ( !activeElement || !container || !tabs.some((tab) => tab.id === activeTab) ) { return; } try { const activeRect = activeElement.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); // Additional validation - ensure we have valid dimensions if (activeRect.width === 0 || containerRect.width === 0) { return; } const relativeLeft = activeRect.left - containerRect.left; setSliderStyle({ width: activeRect.width, transform: `translateX(${relativeLeft}px)`, // We set opacity to 1 once we have positions to avoid FOUC opacity: 1, }); } catch (error) { // Handle potential DOM measurement errors during zoom/resize console.warn("PanelTabs: Error updating slider position:", error); } }; const handleKeyDown = (event: React.KeyboardEvent) => { const focusedTab = tabs.find( (tab) => tabsRef.current[tab.id] === event.currentTarget, ); const currentId = focusedTab?.id ?? activeTab; const nextId = resolvePanelTabKey( event.key, currentId, tabs.map((tab) => tab.id), isRtlContext(event.currentTarget), ); if (nextId === null) { return; } event.preventDefault(); onChange(nextId); requestAnimationFrame(() => { tabsRef.current[nextId]?.focus(); }); }; // Update when active tab changes useLayoutEffect(() => { // Initial calculation needs to happen after paint often to get correct widths // requestAnimationFrame ensures we are measuring at a good time requestAnimationFrame(updateSlider); }, [activeTab]); // Handle window resize useEffect(() => { let resizeTimeout: number; const handleResize = () => { // Debounce resize events to avoid excessive calculations during zoom clearTimeout(resizeTimeout); resizeTimeout = window.setTimeout(() => { // Use requestAnimationFrame to ensure DOM has finished updating requestAnimationFrame(updateSlider); }, 50); }; window.addEventListener("resize", handleResize, { passive: true }); // Also a failsafe timeout for font loading shifts etc const timeout = setTimeout(updateSlider, 150); return () => { window.removeEventListener("resize", handleResize); clearTimeout(timeout); clearTimeout(resizeTimeout); }; }, [activeTab]); // Add activeTab as dependency so resize handler is recreated when tab changes return (
{/* * Animated slider. The motion lives in `.b3-wvs-sub-tabs-slider` rather * than an inline `transition`, so the stylesheet's reduced-motion rule * can actually switch it off; only the measured geometry stays inline. */} ); };