diff --git a/apps/eclipse/content/design-system/components/codeblock.mdx b/apps/eclipse/content/design-system/components/codeblock.mdx index e00500294e..d30af08fd9 100644 --- a/apps/eclipse/content/design-system/components/codeblock.mdx +++ b/apps/eclipse/content/design-system/components/codeblock.mdx @@ -151,12 +151,14 @@ Pre-formatted text wrapper for code content. #### CodeBlockTabs -Container for tabbed code blocks. +Container for tabbed code blocks. Supports both single-axis tabs and multi-axis tabs with variants. **Props:** - `defaultValue` - Default active tab (string, optional) - `value` - Controlled active tab (string, optional) - `onValueChange` - Tab change callback ((value: string) => void, optional) +- `variants` - Secondary axis labels for variant dropdown (string[], optional) +- `defaultVariant` - Default selected variant (string, optional, falls back to variants[0]) - `className` - Additional CSS classes (optional) - All Radix Tabs Root props @@ -180,13 +182,16 @@ Individual tab trigger button. #### CodeBlockTab -Content panel for a code block tab. +Content panel for a code block tab. When using multi-axis tabs, specify the `variant` prop. **Props:** - `value` - Tab identifier matching trigger (string, required) +- `variant` - Variant identifier for multi-axis tabs (string, optional) - `className` - Additional CSS classes (optional) - All Radix Tabs Content props +**Note:** Content is only rendered when both the parent tab is active AND the variant matches the dropdown selection (if variants are used). + ### Features - ✅ Syntax highlighting support (via Shiki/Rehype) @@ -214,6 +219,196 @@ Content panel for a code block tab. ### Common Use Cases +**Multi-Axis Tabs (Package Manager + Language Variants)** + +Use the `variants` prop to add a secondary dropdown axis. This is perfect for showing the same action across different tools and languages: + +````mdx +```tsx + + + pnpm + npm + yarn + + + {/* ── pnpm ── */} + + +
pnpm add @prisma/client{'\n'}pnpm prisma generate
+
+
+ + + +
pnpm add @prisma/client{'\n'}pnpm prisma generate
+
+
+ + + +
pip install prisma{'\n'}prisma generate
+
+
+ + {/* ── npm ── */} + + +
npm install @prisma/client{'\n'}npx prisma generate
+
+
+ + + +
npm install @prisma/client{'\n'}npx prisma generate
+
+
+ + {/* npm + Python intentionally omitted — greyed out in dropdown */} + + {/* ── yarn ── */} + + +
yarn add @prisma/client{'\n'}yarn prisma generate
+
+
+ + + +
yarn add @prisma/client{'\n'}yarn prisma generate
+
+
+ + {/* yarn + Python intentionally omitted — greyed out in dropdown */} +
+``` +```` + +**Live Example:** + + + + pnpm + npm + yarn + + + + +```bash title="install.sh" +pnpm add @prisma/client +pnpm prisma generate +``` + + + + + +```bash title="install.sh" +pnpm add @prisma/client +pnpm prisma generate +``` + + + + + +```bash title="install.sh" +pip install prisma +prisma generate +``` + + + + + +```bash title="install.sh" +npm install @prisma/client +npx prisma generate +``` + + + + + +```bash title="install.sh" +npm install @prisma/client +npx prisma generate +``` + + + + + +```bash title="install.sh" +yarn add @prisma/client +yarn prisma generate +``` + + + + + +```bash title="install.sh" +yarn add @prisma/client +yarn prisma generate +``` + + + + +**Standard Tabs (No Variants)** + +For simpler use cases without variants, the component works as before: + +````mdx +```tsx + + + TypeScript + JavaScript + + + +
const greeting: string = "Hello!";
+
+
+ + +
const greeting = "Hello!";
+
+
+
+``` +```` + +**Live Example:** + + + + TypeScript + JavaScript + + + +
const greeting: string = "Hello!";
+
+
+ + +
const greeting = "Hello!";
+
+
+
+ **API Examples** ```tsx diff --git a/packages/eclipse/src/components/codeblock.tsx b/packages/eclipse/src/components/codeblock.tsx index 11129e3c5b..65b5225a99 100644 --- a/packages/eclipse/src/components/codeblock.tsx +++ b/packages/eclipse/src/components/codeblock.tsx @@ -1,77 +1,83 @@ "use client"; + import { Check, Clipboard } from "lucide-react"; import { type ComponentProps, - createContext, type HTMLAttributes, + type ReactElement, type ReactNode, type RefObject, - useCallback, + Children, + createContext, use, + useCallback, useMemo, useRef, useState, } from "react"; import { cn } from "../lib/cn"; import { buttonVariants } from "./ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "./select"; // adjust to your actual path import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"; import { mergeRefs } from "../lib/merge-refs"; +// --------------------------------------------------------------------------- +// useCopyButton +// --------------------------------------------------------------------------- + function useCopyButton(copy: () => void | Promise, timeout = 2000) { const [checked, setChecked] = useState(false); const onClick = useCallback(async () => { await copy(); setChecked(true); - window.setTimeout(() => { - setChecked(false); - }, timeout); + window.setTimeout(() => setChecked(false), timeout); }, [copy, timeout]); return [checked, onClick] as const; } +// --------------------------------------------------------------------------- +// TabsContext — shared by all CodeBlockTabs children +// --------------------------------------------------------------------------- + +interface TabsContextValue { + containerRef: RefObject; + nested: boolean; + // variant axis (undefined when CodeBlockTabs is used without variants) + variants?: string[]; + activeVariant?: string; + setActiveVariant?: (v: string) => void; + availableVariants?: Set; + availableTabs?: Set; + existingCombos?: Set; +} + +const TabsContext = createContext(null); + +// --------------------------------------------------------------------------- +// CodeBlockProps +// --------------------------------------------------------------------------- + export interface CodeBlockProps extends ComponentProps<"figure"> { - /** - * Icon of code block - * - * When passed as a string, it assumes the value is the HTML of icon - */ icon?: ReactNode; - - /** - * Allow to copy code with copy button - * - * @defaultValue true - */ allowCopy?: boolean; - - /** - * Keep original background color generated by Shiki or Rehype Code - * - * @defaultValue false - */ keepBackground?: boolean; - viewportProps?: HTMLAttributes; - - /** - * show line numbers - */ "data-line-numbers"?: boolean; - - /** - * @defaultValue 1 - */ "data-line-numbers-start"?: number; - Actions?: (props: { className?: string; children?: ReactNode }) => ReactNode; } -const TabsContext = createContext<{ - containerRef: RefObject; - nested: boolean; -} | null>(null); +// --------------------------------------------------------------------------- +// Pre +// --------------------------------------------------------------------------- export function Pre(props: ComponentProps<"pre">) { return ( @@ -84,6 +90,10 @@ export function Pre(props: ComponentProps<"pre">) { ); } +// --------------------------------------------------------------------------- +// CodeBlock +// --------------------------------------------------------------------------- + export function CodeBlock({ ref, title, @@ -111,7 +121,6 @@ export function CodeBlock({ ? "bg-fd-secondary -mx-px -mb-px" : "my-4 bg-fd-card rounded-square", keepBackground && "bg-(--shiki-light-bg) dark:bg-(--shiki-dark-bg)", - "shiki relative border border-stroke-neutral not-prose overflow-hidden type-code-sm", props.className, )} @@ -121,9 +130,7 @@ export function CodeBlock({ {typeof icon === "string" ? (
) : ( icon @@ -152,7 +159,6 @@ export function CodeBlock({ )} style={ { - // space for toolbar "--padding-right": !title ? "calc(var(--spacing) * 8)" : undefined, counterSet: props["data-line-numbers"] ? `line ${Number(props["data-line-numbers-start"] ?? 1) - 1}` @@ -167,6 +173,10 @@ export function CodeBlock({ ); } +// --------------------------------------------------------------------------- +// CopyButton +// --------------------------------------------------------------------------- + function CopyButton({ className, containerRef, @@ -177,12 +187,10 @@ function CopyButton({ const [checked, onClick] = useCopyButton(() => { const pre = containerRef.current?.getElementsByTagName("pre").item(0); if (!pre) return; - const clone = pre.cloneNode(true) as HTMLElement; clone.querySelectorAll(".nd-copy-ignore").forEach((node) => { node.replaceWith("\n"); }); - void navigator.clipboard.writeText(clone.textContent ?? ""); }); @@ -207,58 +215,212 @@ function CopyButton({ ); } -export function CodeBlockTabs({ ref, ...props }: ComponentProps) { +// --------------------------------------------------------------------------- +// CodeBlockTabs +// --------------------------------------------------------------------------- + +export interface CodeBlockTabsProps extends ComponentProps { + /** + * Secondary axis labels rendered as a dropdown on the right of the tab bar. + * When omitted, CodeBlockTabs behaves exactly as before. + */ + variants?: string[]; + /** Default selected variant. Falls back to variants[0]. */ + defaultVariant?: string; +} + +export function CodeBlockTabs({ + ref, + variants, + defaultVariant, + children, + value: controlledValue, + defaultValue, + onValueChange, + ...props +}: CodeBlockTabsProps) { const containerRef = useRef(null); const nested = use(TabsContext) !== null; + // ── track the active tab ── + const [internalActiveTab, setInternalActiveTab] = useState( + controlledValue ?? defaultValue ?? "", + ); + + // Use controlled value if provided, otherwise use internal state + const activeTab = controlledValue ?? internalActiveTab; + + // ── variant state (only when variants are provided) ── + const [activeVariant, setActiveVariant] = useState( + defaultVariant ?? variants?.[0] ?? "", + ); + + // Build the set of existing tab+variant combos by inspecting children. + // We only do this when variants are in use. + const existingCombos = useMemo>(() => { + if (!variants) return new Set(); + const s = new Set(); + Children.forEach(children, (child) => { + const el = child as ReactElement; + const { value, variant } = (el?.props ?? {}) as { + value?: string; + variant?: string; + }; + if (value && variant) s.add(`${value}__${variant}`); + }); + return s; + }, [children, variants]); + + // When the primary tab changes, update the state + function handleTabChange(tab: string) { + console.log("handleTabChange called:", { tab, activeTab, activeVariant }); + setInternalActiveTab(tab); + onValueChange?.(tab); + } + + // Which variants are available for the currently active tab? + const availableVariants = useMemo>(() => { + if (!variants) return new Set(); + const available = new Set( + variants.filter((v) => existingCombos.has(`${activeTab}__${v}`)), + ); + console.log("availableVariants calculation:", { + activeTab, + variants, + existingCombos: Array.from(existingCombos), + available: Array.from(available), + }); + return available; + }, [variants, activeTab, existingCombos]); + + // Which tabs are available for the currently selected variant? + const availableTabs = useMemo>(() => { + if (!variants) return new Set(); + const allTabs = new Set(); + Children.forEach(children, (child) => { + const el = child as ReactElement; + const { value, variant } = (el?.props ?? {}) as { + value?: string; + variant?: string; + }; + if (value && variant === activeVariant) { + allTabs.add(value); + } + }); + return allTabs; + }, [children, variants, activeVariant]); + + const ctxValue = useMemo( + () => ({ + containerRef, + nested, + variants, + activeVariant, + setActiveVariant, + availableVariants, + availableTabs, + existingCombos, + }), + [ + nested, + variants, + activeVariant, + availableVariants, + availableTabs, + existingCombos, + ], + ); + return ( - ({ - containerRef, - nested, - }), - [nested], - )} - > - {props.children} - + {children} ); } +// --------------------------------------------------------------------------- +// CodeBlockTabsList +// --------------------------------------------------------------------------- + export function CodeBlockTabsList(props: ComponentProps) { + const ctx = use(TabsContext); + const hasVariants = !!ctx?.variants?.length; + return ( - {props.children} + {/* Tab pills — left side */} +
{props.children}
+ {/* Variant dropdown — right side, only rendered when variants exist */} + {hasVariants && ctx && ( + + )}
); } +// --------------------------------------------------------------------------- +// CodeBlockTabsTrigger +// --------------------------------------------------------------------------- + export function CodeBlockTabsTrigger({ children, ...props }: ComponentProps) { + const ctx = use(TabsContext); + + // Check if this tab has content for the currently selected variant + const isAvailable = + !ctx?.variants || + !ctx?.availableTabs || + ctx.availableTabs.has(props.value as string); + return ( @@ -268,6 +430,31 @@ export function CodeBlockTabsTrigger({ ); } -export function CodeBlockTab(props: ComponentProps) { - return ; +// --------------------------------------------------------------------------- +// CodeBlockTab +// --------------------------------------------------------------------------- + +export interface CodeBlockTabProps extends ComponentProps { + /** + * When inside a multi-axis CodeBlockTabs, declare which variant this + * content belongs to. Content is only rendered when both the parent tab + * is active AND this variant matches the dropdown selection. + */ + variant?: string; +} + +export function CodeBlockTab({ + variant, + children, + ...props +}: CodeBlockTabProps) { + const ctx = use(TabsContext); + + // If this tab has a variant declared, only render its children when the + // active variant matches. The TabsContent visibility is still controlled + // by Radix (active tab), so we only gate the inner content. + const variantMatch = + !variant || !ctx?.variants || ctx.activeVariant === variant; + + return {variantMatch ? children : null}; }