diff --git a/src/components/MDX/MDXComponents.tsx b/src/components/MDX/MDXComponents.tsx index f24fac5988e..9ff731cb6e5 100644 --- a/src/components/MDX/MDXComponents.tsx +++ b/src/components/MDX/MDXComponents.tsx @@ -25,6 +25,7 @@ import Diagram from './Diagram'; import DiagramGroup from './DiagramGroup'; import SimpleCallout from './SimpleCallout'; import TerminalBlock from './TerminalBlock'; +import TabTerminalBlock from './TabTerminalBlock'; import YouWillLearnCard from './YouWillLearnCard'; import {Challenges, Hint, Solution} from './Challenges'; import {IconNavArrow} from '../Icon/IconNavArrow'; @@ -521,6 +522,7 @@ export const MDXComponents = { SandpackWithHTMLOutput, TeamMember, TerminalBlock, + TabTerminalBlock, YouWillLearn, YouWillLearnCard, Challenges, diff --git a/src/components/MDX/TabTerminalBlock.tsx b/src/components/MDX/TabTerminalBlock.tsx new file mode 100644 index 00000000000..3dc0c8baf29 --- /dev/null +++ b/src/components/MDX/TabTerminalBlock.tsx @@ -0,0 +1,245 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + */ + +import * as React from 'react'; +import {useState, useEffect, useCallback} from 'react'; +import TerminalBlock from './TerminalBlock'; +import {IconTerminal} from '../Icon/IconTerminal'; + +type TabOption = { + label: string; + value: string; + content: string; +}; + +// Define this outside of any conditionals for SSR compatibility +const STORAGE_KEY = 'react-terminal-tabs'; + +// Map key for active tab preferences - only used on client +let activeTabsByKey: Record = {}; +let subscribersByKey: Record void>> = {}; + +function saveToLocalStorage() { + if (typeof window !== 'undefined') { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(activeTabsByKey)); + } catch (e) { + // Ignore errors + } + } +} + +function getSubscribers(key: string): Set<(tab: string) => void> { + if (!subscribersByKey[key]) { + subscribersByKey[key] = new Set(); + } + return subscribersByKey[key]; +} + +function setActiveTab(key: string, tab: string) { + activeTabsByKey[key] = tab; + saveToLocalStorage(); + + const subscribers = getSubscribers(key); + subscribers.forEach((callback) => callback(tab)); +} + +function useTabState( + key: string, + defaultTab: string +): [string, (tab: string) => void] { + // Start with the default tab for SSR + const [activeTab, setLocalActiveTab] = useState(defaultTab); + const [initialized, setInitialized] = useState(false); + + // Initialize from localStorage after mount + useEffect(() => { + // Read from localStorage + try { + const savedState = localStorage.getItem(STORAGE_KEY); + if (savedState) { + const parsed = JSON.parse(savedState); + if (parsed && typeof parsed === 'object') { + Object.assign(activeTabsByKey, parsed); + } + } + } catch (e) { + // Ignore errors + } + + // Set up storage event listener + const handleStorageChange = (e: StorageEvent) => { + if (e.key === STORAGE_KEY && e.newValue) { + try { + const parsed = JSON.parse(e.newValue); + if (parsed && typeof parsed === 'object') { + Object.assign(activeTabsByKey, parsed); + + Object.entries(parsed).forEach(([k, value]) => { + const subscribers = subscribersByKey[k]; + if (subscribers) { + subscribers.forEach((callback) => callback(value as string)); + } + }); + } + } catch (e) { + // Ignore errors + } + } + }; + + window.addEventListener('storage', handleStorageChange); + + // Now get the value from localStorage or keep using default + const storedValue = activeTabsByKey[key] || defaultTab; + setLocalActiveTab(storedValue); + setInitialized(true); + + // Make sure this key is in our global store + if (!activeTabsByKey[key]) { + activeTabsByKey[key] = defaultTab; + saveToLocalStorage(); + } + + return () => { + window.removeEventListener('storage', handleStorageChange); + }; + }, [key, defaultTab]); + + // Set up subscription effect + useEffect(() => { + // Skip if not yet initialized + if (!initialized) return; + + const onTabChange = (newTab: string) => { + setLocalActiveTab(newTab); + }; + + const subscribers = getSubscribers(key); + subscribers.add(onTabChange); + + return () => { + subscribers.delete(onTabChange); + + if (subscribers.size === 0) { + delete subscribersByKey[key]; + } + }; + }, [key, initialized]); + + // Create a stable setter function + const setTab = useCallback( + (newTab: string) => { + setActiveTab(key, newTab); + }, + [key] + ); + + return [activeTab, setTab]; +} + +interface TabTerminalBlockProps { + /** Terminal's message level: info, warning, or error */ + level?: 'info' | 'warning' | 'error'; + + /** + * Tab options, each with a label, value, and content. + * Example: [ + * { label: 'npm', value: 'npm', content: 'npm install react' }, + * { label: 'Bun', value: 'bun', content: 'bun install react' } + * ] + */ + tabs?: Array; + + /** Optional initial active tab value */ + defaultTab?: string; + + /** + * Optional storage key for tab state. + * All TabTerminalBlocks with the same key will share tab selection. + */ + storageKey?: string; +} + +/** + * TabTerminalBlock displays a terminal block with tabs. + * Tabs sync across instances with the same storageKey. + * + * @example + * + */ +function TabTerminalBlock({ + level = 'info', + tabs = [], + defaultTab, + storageKey = 'package-manager', +}: TabTerminalBlockProps) { + // Create a fallback tab if none provided + const safeTabsList = + tabs && tabs.length > 0 + ? tabs + : [{label: 'Terminal', value: 'default', content: 'No content provided'}]; + + // Always use the first tab as initial defaultTab for SSR consistency + // This ensures server and client render the same content initially + const initialDefaultTab = defaultTab || safeTabsList[0].value; + + // Set up tab state + const [activeTab, setTabValue] = useTabState(storageKey, initialDefaultTab); + + const handleTabClick = useCallback( + (tabValue: string) => { + return () => setTabValue(tabValue); + }, + [setTabValue] + ); + + // Handle the case with no content - after hooks have been called + if ( + safeTabsList.length === 0 || + safeTabsList[0].content === 'No content provided' + ) { + return ( + + Error: No tab content provided + + ); + } + + const activeTabOption = + safeTabsList.find((tab) => tab.value === activeTab) || safeTabsList[0]; + + const customHeader = ( +
+ +
+ {safeTabsList.map((tab) => ( + + ))} +
+
+ ); + + return ( + + {activeTabOption.content} + + ); +} + +export default TabTerminalBlock; diff --git a/src/components/MDX/TerminalBlock.tsx b/src/components/MDX/TerminalBlock.tsx index 47529271619..53aea4b98d2 100644 --- a/src/components/MDX/TerminalBlock.tsx +++ b/src/components/MDX/TerminalBlock.tsx @@ -12,6 +12,7 @@ type LogLevel = 'info' | 'warning' | 'error'; interface TerminalBlockProps { level?: LogLevel; children: React.ReactNode; + customHeader?: React.ReactNode; } function LevelText({type}: {type: LogLevel}) { @@ -25,7 +26,11 @@ function LevelText({type}: {type: LogLevel}) { } } -function TerminalBlock({level = 'info', children}: TerminalBlockProps) { +function TerminalBlock({ + level = 'info', + children, + customHeader, +}: TerminalBlockProps) { let message: string | undefined; if (typeof children === 'string') { message = children; @@ -53,15 +58,20 @@ function TerminalBlock({level = 'info', children}: TerminalBlockProps) { }, [copied]); return ( -
+
- Terminal + {customHeader || ( + <> + {' '} + Terminal + + )}