Core & UI: Support separate component for Codeblock Tabs

This commit is contained in:
Fuma Nama
2025-06-30 00:53:30 +08:00
parent bf15617036
commit f8d17097b4
22 changed files with 773 additions and 402 deletions
+26
View File
@@ -0,0 +1,26 @@
---
'fumadocs-docgen': minor
'fumadocs-core': minor
'fumadocs-ui': minor
---
**Redesigned Codeblock Tabs**
Instead of relying on `Tabs` component, it supports a dedicated tabs component for codeblocks:
```tsx
<CodeBlockTabs>
<CodeBlockTabsList>
<CodeBlockTabsTrigger value='value'>
Name
</CodeBlockTabsTrigger>
</CodeBlockTabsList>
<CodeBlockTab value='value' asChild>
<CodeBlock>
...
</CodeBlock>
</CodeBlockTab>
</CodeBlockTabs>
```
The old usage is not deprecated, you can still use them while Fumadocs' remark plugins will generate codeblock tabs using the new way.
+1 -1
View File
@@ -7,7 +7,7 @@ import Spot from '@/public/spot.png';
export default function DocsPage(): React.ReactElement {
return (
<main className="container flex flex-col items-center py-16 text-center z-[2]">
<main className="container flex flex-col items-center py-16 text-center z-2">
<div className="absolute inset-0 z-[-1] overflow-hidden duration-1000 animate-in fade-in [perspective:2000px]">
<div
className="absolute bottom-[20%] left-1/2 size-[1200px] origin-bottom bg-fd-primary/30 opacity-30"
+1 -1
View File
@@ -309,7 +309,7 @@ export function PreviewImages() {
return (
<div className="mt-12 min-w-[800px] overflow-hidden xl:-mx-12 dark:[mask-image:linear-gradient(to_top,transparent,white_40px)]">
<div className="absolute flex flex-row left-1/2 -translate-1/2 bottom-4 z-[2] p-1 rounded-full bg-fd-card border shadow-xl dark:shadow-fd-background">
<div className="absolute flex flex-row left-1/2 -translate-1/2 bottom-4 z-2 p-1 rounded-full bg-fd-card border shadow-xl dark:shadow-fd-background">
<div
role="none"
className="absolute bg-fd-primary rounded-full w-20 h-9 transition-transform z-[-1]"
+4 -4
View File
@@ -54,7 +54,7 @@ export default function Page() {
background: `repeating-linear-gradient(to right, ${gridColor}, ${gridColor} 1px,transparent 1px,transparent 50px), repeating-linear-gradient(to bottom, ${gridColor}, ${gridColor} 1px,transparent 1px,transparent 50px)`,
}}
/>
<main className="container relative max-w-[1100px] px-2 py-4 z-[2] lg:py-8">
<main className="container relative max-w-[1100px] px-2 py-4 z-2 lg:py-8">
<div
style={{
background:
@@ -354,7 +354,7 @@ function Highlight({
function Hero() {
return (
<div className="relative z-[2] flex flex-col border-x border-t bg-fd-background/80 px-4 pt-12 max-md:text-center md:px-12 md:pt-16 [.uwu_&]:hidden overflow-hidden">
<div className="relative z-2 flex flex-col border-x border-t bg-fd-background/80 px-4 pt-12 max-md:text-center md:px-12 md:pt-16 [.uwu_&]:hidden overflow-hidden">
<div
className="absolute inset-0 z-[-1] blur-2xl hidden dark:block"
style={{
@@ -516,7 +516,7 @@ title: My Documentation
Hello World
`}
/>
<Files className="z-[2] mt-40 shadow-xl">
<Files className="z-2 mt-40 shadow-xl">
<Folder name="content" defaultOpen>
<File name="index.mdx" />
<File name="components.mdx" />
@@ -622,7 +622,7 @@ function Features() {
sizes="600px"
className="-mt-16 w-[400px] min-w-[400px] invert pointer-events-none dark:invert-0"
/>
<div className="z-[2] mt-[-170px] w-[300px] overflow-hidden rounded-lg border border-fd-foreground/10 shadow-xl backdrop-blur-lg">
<div className="z-2 mt-[-170px] w-[300px] overflow-hidden rounded-lg border border-fd-foreground/10 shadow-xl backdrop-blur-lg">
<div className="flex flex-row items-center gap-2 bg-fd-muted/50 px-4 py-2 text-xs font-medium text-fd-muted-foreground">
<FileEditIcon className="size-4" />
MDX Editor
+2 -2
View File
@@ -180,7 +180,7 @@ const vercel = [
export default function Showcase() {
return (
<main className="px-4 py-12 z-[2] w-full max-w-[1400px] mx-auto [--color-fd-border:color-mix(in_oklab,var(--color-fd-primary)_30%,transparent)]">
<main className="px-4 py-12 z-2 w-full max-w-[1400px] mx-auto [--color-fd-border:color-mix(in_oklab,var(--color-fd-primary)_30%,transparent)]">
<div className="relative overflow-hidden border border-dashed p-6">
<h1 className="mb-4 text-xl font-medium">
The docs framework designed with care.
@@ -297,7 +297,7 @@ function ShowcaseItem({ name, url, image }: ShowcaseObject) {
sizes="100vw, (min-width: 750px) 500px"
className="object-cover transition-all group-hover:brightness-150"
/>
<p className="absolute bottom-0 inset-x-0 z-[2] bg-fd-background px-4 py-2 text-sm font-medium">
<p className="absolute bottom-0 inset-x-0 z-2 bg-fd-background px-4 py-2 text-sm font-medium">
{name}
</p>
</a>
+1 -1
View File
@@ -50,7 +50,7 @@ export default async function Page() {
});
return (
<main className="container flex flex-col items-center py-16 text-center z-[2]">
<main className="container flex flex-col items-center py-16 text-center z-2">
<Image
src="/circuit_2.svg"
alt="circuit"
+1 -1
View File
@@ -6,7 +6,7 @@ import Logo from '@/public/logo.png';
export function UwuHero() {
return (
<div className="z-[2] hidden flex-col items-center pb-8 text-center bg-fd-background border-x border-t [.uwu_&]:flex">
<div className="z-2 hidden flex-col items-center pb-8 text-center bg-fd-background border-x border-t [.uwu_&]:flex">
<Image
alt="logo"
src={Logo}
@@ -131,13 +131,13 @@ Create a catch-all route `/app/docs/[[...slug]]` for docs pages.
In the page, wrap your content in the [Page](/docs/ui/layouts/page) component.
<Tabs groupId='content-source' items={['Fumadocs MDX', 'Content Collections']}>
<CodeBlockTabs groupId='content-source'>
<include cwd meta='title="app/docs/[[...slug]]/page.tsx" tab="Fumadocs MDX"'>../../examples/next-mdx/app/docs/[[...slug]]/page.tsx</include>
<include cwd meta='title="app/docs/[[...slug]]/page.tsx" tab="Content Collections"'>../../examples/content-collections/app/docs/[[...slug]]/page.tsx</include>
</Tabs>
</CodeBlockTabs>
### Search
+1 -1
View File
@@ -91,7 +91,7 @@ export default defineConfig({
remarkSteps,
remarkMath,
remarkAutoTypeTable,
[remarkInstall, { persist: { id: 'package-manager' } }],
[remarkInstall, { mode: 'auto', persist: { id: 'package-manager' } }],
remarkTypeScriptToJavaScript,
],
rehypePlugins: (v) => [rehypeKatex, ...v],
@@ -197,6 +197,9 @@ function transformerTab(): ShikiTransformer {
root(root) {
const value = this.options.meta?.tab;
if (typeof value !== 'string') return root;
console.warn(
'[Fumadocs] For `tab="value" in codeblocks, please use `remarkCodeTab` plugin instead.',
);
return {
type: 'root',
+241 -113
View File
@@ -5,7 +5,10 @@ import type { MdxJsxFlowElement } from 'mdast-util-mdx-jsx';
const TabRegex = /tab="(.+?)"/;
type TabType = keyof typeof Types;
export interface RemarkCodeTabOptions {
Tabs?: TabType;
/**
* Parse MDX in tab values
*
@@ -14,16 +17,230 @@ export interface RemarkCodeTabOptions {
parseMdx?: boolean;
}
const Tabs = {
convert(
processor: Processor,
nodes: Code[],
withMdx = false,
withParent = true,
): MdxJsxFlowElement {
const names = processTabValue(nodes);
if (!withMdx) {
const children: MdxJsxFlowElement[] = nodes.map((node, i) => {
return {
type: 'mdxJsxFlowElement',
name: 'Tab',
attributes: [
{
type: 'mdxJsxAttribute',
name: 'value',
value: names[i],
},
],
children: [node],
};
});
if (!withParent) return createFragment(children);
return {
type: 'mdxJsxFlowElement',
name: 'Tabs',
attributes: [
{
type: 'mdxJsxAttribute',
name: 'items',
value: {
type: 'mdxJsxAttributeValueExpression',
value: names.join(', '),
data: {
estree: {
type: 'Program',
sourceType: 'module',
comments: [],
body: [
{
type: 'ExpressionStatement',
expression: {
type: 'ArrayExpression',
elements: names.map((name) => ({
type: 'Literal',
value: name,
})),
},
},
],
},
},
},
},
],
children,
};
}
const children: MdxJsxFlowElement[] = [
{
type: 'mdxJsxFlowElement',
name: 'TabsList',
attributes: [],
children: names.map((name) => ({
type: 'mdxJsxFlowElement',
name: 'TabsTrigger',
attributes: [
{
type: 'mdxJsxAttribute',
name: 'value',
value: name,
},
],
children: [
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- needed
mdxToAst(processor, name) as any,
],
})),
},
...nodes.map(
(node, i) =>
({
type: 'mdxJsxFlowElement',
name: 'TabsContent',
attributes: [
{
type: 'mdxJsxAttribute',
name: 'value',
value: names[i],
},
],
children: [node],
}) as MdxJsxFlowElement,
),
];
if (!withParent) return createFragment(children);
return {
type: 'mdxJsxFlowElement',
name: 'Tabs',
attributes: [
{
type: 'mdxJsxAttribute',
name: 'defaultValue',
value: names[0],
},
],
children,
};
},
};
const CodeBlockTabs = {
convert(
processor: Processor,
nodes: Code[],
withMdx = false,
withParent = true,
): MdxJsxFlowElement {
const names = processTabValue(nodes);
const children: MdxJsxFlowElement[] = [
{
type: 'mdxJsxFlowElement',
name: 'CodeBlockTabsList',
attributes: [],
children: names.map((name) => {
return {
type: 'mdxJsxFlowElement',
name: 'CodeBlockTabsTrigger',
attributes: [
{
type: 'mdxJsxAttribute',
name: 'value',
value: name,
},
],
children: [
withMdx
? // eslint-disable-next-line @typescript-eslint/no-explicit-any -- needed
(mdxToAst(processor, name) as any)
: {
type: 'text',
value: name,
},
],
};
}),
},
...nodes.map((node, i) => {
return {
type: 'mdxJsxFlowElement',
name: 'CodeBlockTab',
attributes: [
{
type: 'mdxJsxAttribute',
name: 'value',
value: names[i],
},
],
children: [node],
} as MdxJsxFlowElement;
}),
];
if (!withParent) return createFragment(children);
return {
type: 'mdxJsxFlowElement',
name: 'CodeBlockTabs',
attributes: [
{
type: 'mdxJsxAttribute',
name: 'defaultValue',
value: names[0],
},
],
children,
};
},
};
const Types = {
CodeBlockTabs,
Tabs,
};
export function remarkCodeTab(
this: Processor,
options: RemarkCodeTabOptions = {},
): Transformer<Root, Root> {
const { parseMdx = false, Tabs = 'CodeBlockTabs' } = options;
return (tree) => {
visit(tree, (node) => {
if (!('children' in node)) return;
if (node.type === 'mdxJsxFlowElement' && node.name === 'Tabs') return;
let start = -1;
let i = 0;
let localTabs: TabType = Tabs;
let localParseMdx = parseMdx;
let withParent = true;
if (
node.type === 'mdxJsxFlowElement' &&
node.name &&
node.name in Types
) {
withParent = false;
localTabs = node.name as TabType;
// for `Tabs` in simple mode, it doesn't support MDX tab names
if (node.name === 'Tabs') {
localParseMdx = node.attributes.every(
(attribute) =>
attribute.type !== 'mdxJsxAttribute' ||
attribute.name !== 'items',
);
}
}
while (i < node.children.length) {
const child = node.children[i];
@@ -39,13 +256,14 @@ export function remarkCodeTab(
if (start !== -1 && (isLast || !isSwitcher)) {
const end = isSwitcher ? i + 1 : i;
const targets = node.children.slice(start, end) as Code[];
node.children.splice(
start,
end - start,
options.parseMdx ? toTabMdx(this, targets) : toTab(targets),
const replacement = Types[localTabs].convert(
this,
targets,
localParseMdx,
withParent,
);
node.children.splice(start, end - start, replacement);
if (isLast) break;
i = start + 1;
start = -1;
@@ -70,118 +288,28 @@ function processTabValue(nodes: Code[]) {
});
}
function toTab(nodes: Code[]): MdxJsxFlowElement {
const names = processTabValue(nodes);
/**
* MDX tab name to tab trigger node children
*/
function mdxToAst(processor: Processor, name: string) {
const node = processor.parse(name) as Root;
return {
type: 'mdxJsxFlowElement',
name: 'Tabs',
attributes: [
{
type: 'mdxJsxAttribute',
name: 'items',
value: {
type: 'mdxJsxAttributeValueExpression',
value: names.join(', '),
data: {
estree: {
type: 'Program',
sourceType: 'module',
comments: [],
body: [
{
type: 'ExpressionStatement',
expression: {
type: 'ArrayExpression',
elements: names.map((name) => ({
type: 'Literal',
value: name,
})),
},
},
],
},
},
},
},
],
children: nodes.map((node, i) => {
return {
type: 'mdxJsxFlowElement',
name: 'Tab',
attributes: [
{
type: 'mdxJsxAttribute',
name: 'value',
value: names[i],
},
],
children: [node],
};
}),
};
}
if (node.type === 'root') {
node.children = node.children.flatMap((child) => {
if (child.type === 'paragraph') return child.children;
function toTabMdx(processor: Processor, nodes: Code[]): MdxJsxFlowElement {
const names = processTabValue(nodes);
function inline(node: Root) {
if (node.type === 'root') {
node.children = node.children.flatMap((child) => {
if (child.type === 'paragraph') return child.children;
return child;
});
}
return node;
return child;
});
}
return node;
}
function createFragment(children: MdxJsxFlowElement[]): MdxJsxFlowElement {
return {
type: 'mdxJsxFlowElement',
name: 'Tabs',
attributes: [
{
type: 'mdxJsxAttribute',
name: 'defaultValue',
value: names[0],
},
],
children: [
{
type: 'mdxJsxFlowElement',
name: 'TabsList',
attributes: [],
children: names.map((name) => ({
type: 'mdxJsxFlowElement',
name: 'TabsTrigger',
attributes: [
{
type: 'mdxJsxAttribute',
name: 'value',
value: name,
},
],
children: [
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- needed
inline(processor.parse(name) as Root) as any,
],
})),
},
...nodes.map(
(node, i) =>
({
type: 'mdxJsxFlowElement',
name: 'TabsContent',
attributes: [
{
type: 'mdxJsxAttribute',
name: 'value',
value: names[i],
},
],
children: [node],
}) satisfies MdxJsxFlowElement,
),
],
name: null,
attributes: [],
children,
};
}
+105 -35
View File
@@ -3,6 +3,7 @@ import type { Transformer } from 'unified';
import { visit } from 'unist-util-visit';
import convert from 'npm-to-yarn';
import { createElement, expressionToAttribute } from './utils';
import { MdxJsxAttribute, MdxJsxFlowElement } from 'mdast-util-mdx-jsx';
interface PackageManager {
name: string;
@@ -13,10 +14,7 @@ interface PackageManager {
command: (command: string) => string;
}
export type RemarkInstallOptions = Partial<{
Tabs: string;
Tab: string;
interface BaseOptions {
/**
* Persist Tab value (Fumadocs UI only)
*
@@ -28,8 +26,20 @@ export type RemarkInstallOptions = Partial<{
}
| false;
packageManagers: PackageManager[];
}>;
packageManagers?: PackageManager[];
}
export type RemarkInstallOptions = BaseOptions &
(
| {
mode?: 'legacy';
Tabs?: string;
Tab?: string;
}
| {
mode: 'auto';
}
);
/**
* It generates the following structure from a code block with `package-install` as language
@@ -43,8 +53,6 @@ export type RemarkInstallOptions = Partial<{
* ```
*/
export function remarkInstall({
Tab = 'Tab',
Tabs = 'Tabs',
persist = false,
packageManagers = [
{ command: (cmd) => convert(cmd, 'npm'), name: 'npm' },
@@ -52,47 +60,109 @@ export function remarkInstall({
{ command: (cmd) => convert(cmd, 'yarn'), name: 'yarn' },
{ command: (cmd) => convert(cmd, 'bun'), name: 'bun' },
],
...options
}: RemarkInstallOptions = {}): Transformer<Root, Root> {
return (tree) => {
visit(tree, 'code', (node) => {
if (node.lang !== 'package-install') return 'skip';
const value =
node.value.startsWith('npm') || node.value.startsWith('npx')
? node.value
: `npm install ${node.value}`;
const attributes: MdxJsxAttribute[] = [];
if (typeof persist === 'object') {
attributes.push(
{
type: 'mdxJsxAttribute',
name: 'groupId',
value: persist.id,
},
{
type: 'mdxJsxAttribute',
name: 'persist',
value: null,
},
);
}
if (options.mode === 'auto') {
attributes.push({
type: 'mdxJsxAttribute',
name: 'defaultValue',
value: packageManagers[0].name,
});
const children: MdxJsxFlowElement[] = [
{
type: 'mdxJsxFlowElement',
name: 'CodeBlockTabsList',
attributes: [],
children: packageManagers.map(
({ name }) =>
({
type: 'mdxJsxFlowElement',
attributes: [
{ type: 'mdxJsxAttribute', name: 'value', value: name },
],
name: 'CodeBlockTabsTrigger',
children: [
{
type: 'text',
value: name,
} as unknown,
],
}) as MdxJsxFlowElement,
),
},
...packageManagers.map(
({ command, name }) =>
({
type: 'mdxJsxFlowElement',
name: 'CodeBlockTab',
attributes: [
{ type: 'mdxJsxAttribute', name: 'value', value: name },
],
children: [
{
type: 'code',
lang: 'bash',
meta: node.meta,
value: command(value),
},
],
}) as MdxJsxFlowElement,
),
];
const tab: MdxJsxFlowElement = {
type: 'mdxJsxFlowElement',
name: 'CodeBlockTabs',
attributes,
children,
};
Object.assign(node, tab);
return;
}
const { Tabs = 'Tabs', Tab = 'Tab' } = options;
attributes.push(
expressionToAttribute('items', {
type: 'ArrayExpression',
elements: packageManagers.map(({ name }) => ({
type: 'Literal',
value: name,
})),
}),
);
const insert = createElement(
Tabs,
[
...(typeof persist === 'object'
? [
{
type: 'mdxJsxAttribute',
name: 'groupId',
value: persist.id,
},
{
type: 'mdxJsxAttribute',
name: 'persist',
value: null,
},
]
: []),
expressionToAttribute('items', {
type: 'ArrayExpression',
elements: packageManagers.map(({ name }) => ({
type: 'Literal',
value: name,
})),
}),
],
attributes,
packageManagers.map(({ command, name }) => ({
type: 'mdxJsxFlowElement',
name: Tab,
attributes: [
{ type: 'mdxJsxAttribute', name: 'value', value: name },
].filter(Boolean),
attributes: [{ type: 'mdxJsxAttribute', name: 'value', value: name }],
children: [
{
type: 'code',
+6 -1
View File
@@ -1,4 +1,5 @@
import type { Expression, Program } from 'estree';
import type { MdxJsxAttribute } from 'mdast-util-mdx-jsx';
export function createElement(
name: string,
@@ -16,12 +17,16 @@ export function createElement(
return element;
}
export function expressionToAttribute(key: string, value: Expression): object {
export function expressionToAttribute(
key: string,
value: Expression,
): MdxJsxAttribute {
return {
type: 'mdxJsxAttribute',
name: key,
value: {
type: 'mdxJsxAttributeValueExpression',
value: '',
data: {
estree: {
type: 'Program',
+1 -1
View File
@@ -30,7 +30,7 @@
--color-fd-primary: hsl(0, 0%, 98%);
--color-fd-primary-foreground: hsl(0, 0%, 9%);
--color-fd-secondary: hsl(0, 0%, 12.9%);
--color-fd-secondary-foreground: hsl(0, 0%, 98%);
--color-fd-secondary-foreground: hsl(0, 0%, 92%);
--color-fd-accent: hsla(0, 0%, 40.9%, 30%);
--color-fd-accent-foreground: hsl(0, 0%, 90%);
--color-fd-ring: hsl(0, 0%, 54.9%);
+2 -2
View File
@@ -110,14 +110,14 @@
@keyframes fd-popover-in {
from {
opacity: 0;
transform: translateY(-12px) scale(0.9);
transform: scale(0.7);
}
}
@keyframes fd-popover-out {
to {
opacity: 0;
transform: translateY(-12px) scale(0.9);
transform: scale(0.7);
}
}
+4 -1
View File
@@ -1,11 +1,13 @@
.shiki:not(.not-fumadocs-codeblock *) {
--padding-x: calc(var(--spacing) * 4);
code span {
color: var(--shiki-light);
}
code .line {
min-height: 1lh;
@apply relative;
@apply relative px-(--padding-x);
}
&.has-focused code .line:not(.focused) {
@@ -24,6 +26,7 @@
&[data-line-numbers] code .line {
width: calc(100% - 7 * var(--spacing));
counter-increment: line;
padding-left: calc(var(--padding-x) - var(--spacing));
@apply ml-7;
&::after {
+142 -52
View File
@@ -2,14 +2,25 @@
import { Check, Copy } from 'lucide-react';
import {
type ComponentProps,
createContext,
forwardRef,
type HTMLAttributes,
type ReactNode,
type RefObject,
useContext,
useMemo,
useRef,
} from 'react';
import { cn } from '@/utils/cn';
import { useCopyButton } from '@/utils/use-copy-button';
import { buttonVariants } from '@/components/ui/button';
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/components/tabs.unstyled';
import { mergeRefs } from '@/utils/merge-refs';
export type CodeBlockProps = HTMLAttributes<HTMLElement> & {
/**
@@ -44,8 +55,14 @@ export type CodeBlockProps = HTMLAttributes<HTMLElement> & {
* @defaultValue 1
*/
'data-line-numbers-start'?: number;
Actions?: (props: { className?: string; children?: ReactNode }) => ReactNode;
};
const TabsContext = createContext<{
containerRef: RefObject<HTMLDivElement | null>;
} | null>(null);
export const Pre = forwardRef<HTMLPreElement, HTMLAttributes<HTMLPreElement>>(
({ className, ...props }, ref) => {
return (
@@ -66,27 +83,21 @@ export const CodeBlock = forwardRef<HTMLElement, CodeBlockProps>(
(
{
title,
allowCopy = true,
allowCopy,
keepBackground = false,
icon,
viewportProps,
viewportProps = {},
children,
Actions = (props) => (
<div {...props} className={cn('empty:hidden', props.className)} />
),
...props
},
ref,
) => {
const isTab = useContext(TabsContext) !== null;
const areaRef = useRef<HTMLDivElement>(null);
const onCopy = () => {
const pre = areaRef.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 ?? '');
};
allowCopy ??= !isTab;
return (
<figure
@@ -94,54 +105,60 @@ export const CodeBlock = forwardRef<HTMLElement, CodeBlockProps>(
dir="ltr"
{...props}
className={cn(
'not-prose group relative my-4 overflow-hidden rounded-lg border bg-fd-card text-sm outline-none',
isTab
? 'mb-1 mx-1 rounded-lg shadow-sm bg-fd-secondary'
: 'my-4 rounded-xl bg-fd-card p-1',
keepBackground && 'bg-(--shiki-light-bg) dark:bg-(--shiki-dark-bg)',
'relative border outline-none not-prose overflow-hidden text-sm',
props.className,
)}
>
{title ? (
<div className="flex items-center gap-2 bg-fd-secondary px-4 py-1.5">
{icon ? (
<div
className={cn(
'flex text-fd-muted-foreground items-center gap-2 ps-3 h-9.5',
isTab && 'border-b',
)}
>
{typeof icon === 'string' ? (
<div
className="text-fd-muted-foreground [&_svg]:size-3.5"
dangerouslySetInnerHTML={
typeof icon === 'string'
? {
__html: icon,
}
: undefined
}
>
{typeof icon !== 'string' ? icon : null}
</div>
) : null}
<figcaption className="flex-1 truncate text-fd-muted-foreground">
{title}
</figcaption>
{allowCopy && <CopyButton className="-me-2" onCopy={onCopy} />}
className="[&_svg]:size-3.5"
dangerouslySetInnerHTML={{
__html: icon,
}}
/>
) : (
icon
)}
<figcaption className="flex-1 truncate">{title}</figcaption>
{Actions({
children: allowCopy && <CopyButton containerRef={areaRef} />,
})}
</div>
) : (
allowCopy && (
<CopyButton
className="absolute right-2 top-2 z-[2] backdrop-blur-md"
onCopy={onCopy}
/>
)
Actions({
className:
'absolute top-1 right-1 z-2 bg-fd-card rounded-bl-lg border-l border-b text-fd-muted-foreground',
children: allowCopy && <CopyButton containerRef={areaRef} />,
})
)}
<div
ref={areaRef}
{...viewportProps}
className={cn(
'text-[13px] py-3.5 overflow-auto [&_.line]:px-4 max-h-[600px] fd-scroll-container',
props['data-line-numbers'] && '[&_.line]:pl-3',
viewportProps?.className,
!isTab && 'bg-fd-secondary rounded-lg border',
'text-[13px] py-3.5 overflow-auto max-h-[600px] fd-scroll-container',
viewportProps.className,
)}
style={{
counterSet: props['data-line-numbers']
? `line ${Number(props['data-line-numbers-start'] ?? 1) - 1}`
: undefined,
...viewportProps?.style,
}}
style={
{
'--padding-x': 'calc(var(--spacing) * 3)',
counterSet: props['data-line-numbers']
? `line ${Number(props['data-line-numbers-start'] ?? 1) - 1}`
: undefined,
...viewportProps.style,
} as object
}
>
{children}
</div>
@@ -154,12 +171,22 @@ CodeBlock.displayName = 'CodeBlock';
function CopyButton({
className,
onCopy,
containerRef,
...props
}: ComponentProps<'button'> & {
onCopy: () => void;
containerRef: RefObject<HTMLElement | null>;
}) {
const [checked, onClick] = useCopyButton(onCopy);
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 ?? '');
});
return (
<button
@@ -167,9 +194,8 @@ function CopyButton({
className={cn(
buttonVariants({
color: 'ghost',
className: 'relative [&_svg]:size-3.5',
}),
'transition-opacity group-hover:opacity-100 [&_svg]:size-3.5',
!checked && '[@media(hover:hover)]:opacity-0',
className,
)}
aria-label={checked ? 'Copied Text' : 'Copy Text'}
@@ -183,3 +209,67 @@ function CopyButton({
</button>
);
}
export function CodeBlockTabs(props: ComponentProps<typeof Tabs>) {
const ref = useRef<HTMLDivElement>(null);
return (
<Tabs
ref={mergeRefs(ref, props.ref)}
{...props}
className={cn(
'bg-fd-card my-4 rounded-xl border overflow-hidden',
props.className,
)}
>
<TabsContext.Provider
value={useMemo(
() => ({
containerRef: ref,
}),
[],
)}
>
{props.children}
</TabsContext.Provider>
</Tabs>
);
}
export function CodeBlockTabsList(props: ComponentProps<typeof TabsList>) {
const { containerRef } = useContext(TabsContext)!;
return (
<TabsList
{...props}
className={cn(
'flex flex-row overflow-x-auto p-1 text-fd-muted-foreground',
props.className,
)}
>
{props.children}
<CopyButton
className="sticky ms-auto right-0 bg-fd-card backdrop-blur-sm"
containerRef={containerRef}
/>
</TabsList>
);
}
export function CodeBlockTabsTrigger(
props: ComponentProps<typeof TabsTrigger>,
) {
return (
<TabsTrigger
{...props}
className={cn(
'inline-flex text-sm font-medium transition-colors items-center gap-2 px-3 py-1.5 outline-none rounded-lg hover:text-fd-accent-foreground data-[state=active]:bg-fd-primary/10 data-[state=active]:text-fd-primary [&_svg]:size-3.5',
props.className,
)}
/>
);
}
export function CodeBlockTab(props: ComponentProps<typeof TabsContent>) {
return <TabsContent {...props} asChild />;
}
@@ -546,7 +546,7 @@ function Border({ level, active }: { level: number; active?: boolean }) {
return (
<div
className={cn(
'absolute w-px inset-y-3 z-[2] start-2.5 md:inset-y-2',
'absolute w-px inset-y-3 z-2 start-2.5 md:inset-y-2',
active && 'bg-fd-primary',
)}
/>
+54 -113
View File
@@ -1,5 +1,6 @@
'use client';
import * as React from 'react';
import {
type ComponentProps,
createContext,
@@ -7,50 +8,19 @@ import {
useContext,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { cn } from '@/utils/cn';
import * as Primitive from './ui/tabs';
import { useEffectEvent } from 'fumadocs-core/utils/use-effect-event';
import { mergeRefs } from '@/utils/merge-refs';
import * as Unstyled from './tabs.unstyled';
type CollectionKey = string | symbol;
type ChangeListener = (v: string) => void;
const listeners = new Map<string, ChangeListener[]>();
function addChangeListener(id: string, listener: ChangeListener): void {
const list = listeners.get(id) ?? [];
list.push(listener);
listeners.set(id, list);
}
function removeChangeListener(id: string, listener: ChangeListener): void {
const list = listeners.get(id) ?? [];
listeners.set(
id,
list.filter((item) => item !== listener),
);
}
export interface TabsProps extends ComponentProps<typeof Primitive.Tabs> {
/**
* Identifier for Sharing value of tabs
*/
groupId?: string;
/**
* Enable persistent
*/
persist?: boolean;
/**
* If true, updates the URL hash based on the tab's id
*/
updateAnchor?: boolean;
export interface TabsProps
extends Omit<
ComponentProps<typeof Unstyled.Tabs>,
'value' | 'onValueChange'
> {
/**
* Use simple mode instead of advanced usage as documented in https://radix-ui.com/primitives/docs/components/tabs.
*/
@@ -71,7 +41,6 @@ export interface TabsProps extends ComponentProps<typeof Primitive.Tabs> {
const TabsContext = createContext<{
items?: string[];
valueToIdMap: Map<string, string>;
collection: CollectionKey[];
} | null>(null);
@@ -81,82 +50,60 @@ function useTabContext() {
return ctx;
}
export const TabsList = Primitive.TabsList;
export const TabsTrigger = Primitive.TabsTrigger;
export const TabsList = React.forwardRef<
React.ComponentRef<typeof Unstyled.TabsList>,
React.ComponentPropsWithoutRef<typeof Unstyled.TabsList>
>((props, ref) => (
<Unstyled.TabsList
ref={ref}
{...props}
className={cn(
'flex gap-3.5 text-fd-secondary-foreground overflow-x-auto px-4 not-prose',
props.className,
)}
/>
));
TabsList.displayName = 'TabsList';
export const TabsTrigger = React.forwardRef<
React.ComponentRef<typeof Unstyled.TabsTrigger>,
React.ComponentPropsWithoutRef<typeof Unstyled.TabsTrigger>
>((props, ref) => (
<Unstyled.TabsTrigger
ref={ref}
{...props}
className={cn(
'inline-flex items-center gap-2 whitespace-nowrap text-fd-muted-foreground border-b border-transparent py-2 text-sm font-medium transition-colors [&_svg]:size-4 hover:text-fd-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=active]:border-fd-primary data-[state=active]:text-fd-primary',
props.className,
)}
/>
));
TabsTrigger.displayName = 'TabsTrigger';
export function Tabs({
ref,
className,
groupId,
items,
persist = false,
label,
defaultIndex = 0,
updateAnchor = false,
defaultValue = items ? escapeValue(items[defaultIndex]) : undefined,
...props
}: TabsProps) {
const tabsRef = useRef<HTMLDivElement>(null);
const [value, setValue] = useState(defaultValue);
const valueToIdMap = useMemo(() => new Map<string, string>(), []);
const collection = useMemo<CollectionKey[]>(() => [], []);
const onUpdate: ChangeListener = useEffectEvent((v) => {
if (items && !items.some((item) => escapeValue(item) === v)) return;
setValue(v);
});
useLayoutEffect(() => {
if (!groupId) return;
const previous = persist
? localStorage.getItem(groupId)
: sessionStorage.getItem(groupId);
if (previous) onUpdate(previous);
addChangeListener(groupId, onUpdate);
return () => {
removeChangeListener(groupId, onUpdate);
};
}, [groupId, onUpdate, persist]);
useLayoutEffect(() => {
const hash = window.location.hash.slice(1);
if (!hash) return;
for (const [value, id] of valueToIdMap.entries()) {
if (id === hash) {
setValue(value);
tabsRef.current?.scrollIntoView();
break;
}
}
}, [valueToIdMap]);
return (
<Primitive.Tabs
ref={mergeRefs(ref, tabsRef)}
<Unstyled.Tabs
ref={ref}
className={cn(
'flex flex-col overflow-hidden rounded-xl border bg-fd-secondary my-4',
className,
)}
value={value}
onValueChange={(v: string) => {
if (updateAnchor) {
const id = valueToIdMap.get(v);
if (id) {
window.history.replaceState(null, '', `#${id}`);
}
}
if (groupId) {
listeners.get(groupId)?.forEach((item) => {
item(v);
});
if (persist) localStorage.setItem(groupId, v);
else sessionStorage.setItem(groupId, v);
} else {
setValue(v);
}
if (items && !items.some((item) => escapeValue(item) === v)) return;
setValue(v);
}}
className={cn('my-4', className)}
{...props}
>
{items && (
@@ -172,19 +119,16 @@ export function Tabs({
</TabsList>
)}
<TabsContext.Provider
value={useMemo(
() => ({ items, valueToIdMap, collection }),
[valueToIdMap, collection, items],
)}
value={useMemo(() => ({ items, collection }), [collection, items])}
>
{props.children}
</TabsContext.Provider>
</Primitive.Tabs>
</Unstyled.Tabs>
);
}
export interface TabProps
extends Omit<ComponentProps<typeof Primitive.TabsContent>, 'value'> {
extends Omit<ComponentProps<typeof Unstyled.TabsContent>, 'value'> {
/**
* Value of tab, detect from index if unspecified.
*/
@@ -213,25 +157,19 @@ export function TabsContent({
value,
className,
...props
}: ComponentProps<typeof Primitive.TabsContent>) {
const { valueToIdMap } = useTabContext();
if (props.id) {
valueToIdMap.set(value, props.id);
}
}: ComponentProps<typeof Unstyled.TabsContent>) {
return (
<Primitive.TabsContent
<Unstyled.TabsContent
value={value}
forceMount
className={cn(
'prose-no-margin data-[state=inactive]:hidden [&>figure:only-child]:-m-4 [&>figure:only-child]:border-none',
'p-4 text-[15px] bg-fd-background rounded-xl outline-none prose-no-margin data-[state=inactive]:hidden [&>figure:only-child]:-m-4 [&>figure:only-child]:border-none',
className,
)}
{...props}
>
{props.children}
</Primitive.TabsContent>
</Unstyled.TabsContent>
);
}
@@ -256,6 +194,9 @@ function useCollectionIndex() {
return collection.indexOf(key);
}
/**
* only escape whitespaces in values in simple mode
*/
function escapeValue(v: string): string {
return v.toLowerCase().replace(/\s/, '-');
}
@@ -0,0 +1,163 @@
'use client';
import {
type ComponentProps,
createContext,
useContext,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import * as Primitive from '@radix-ui/react-tabs';
import { mergeRefs } from '@/utils/merge-refs';
import { useEffectEvent } from 'fumadocs-core/utils/use-effect-event';
type ChangeListener = (v: string) => void;
const listeners = new Map<string, ChangeListener[]>();
function addChangeListener(id: string, listener: ChangeListener): void {
const list = listeners.get(id) ?? [];
list.push(listener);
listeners.set(id, list);
}
function removeChangeListener(id: string, listener: ChangeListener): void {
const list = listeners.get(id) ?? [];
listeners.set(
id,
list.filter((item) => item !== listener),
);
}
export interface TabsProps extends ComponentProps<typeof Primitive.Tabs> {
/**
* Identifier for Sharing value of tabs
*/
groupId?: string;
/**
* Enable persistent
*/
persist?: boolean;
/**
* If true, updates the URL hash based on the tab's id
*/
updateAnchor?: boolean;
}
const TabsContext = createContext<{
valueToIdMap: Map<string, string>;
} | null>(null);
function useTabContext() {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error('You must wrap your component in <Tabs>');
return ctx;
}
export const TabsList = Primitive.TabsList;
export const TabsTrigger = Primitive.TabsTrigger;
/**
* @internal You better not use it
*/
export function Tabs({
ref,
groupId,
persist = false,
updateAnchor = false,
defaultValue,
value: _value,
onValueChange: _onValueChange,
...props
}: TabsProps) {
const tabsRef = useRef<HTMLDivElement>(null);
const [value, setValue] =
_value === undefined
? // eslint-disable-next-line react-hooks/rules-of-hooks -- not supposed to change controlled/uncontrolled
useState(defaultValue)
: [_value, _onValueChange ?? (() => undefined)];
const onChange = useEffectEvent((v: string) => setValue(v));
const valueToIdMap = useMemo(() => new Map<string, string>(), []);
useLayoutEffect(() => {
if (!groupId) return;
const previous = persist
? localStorage.getItem(groupId)
: sessionStorage.getItem(groupId);
if (previous) onChange(previous);
addChangeListener(groupId, onChange);
return () => {
removeChangeListener(groupId, onChange);
};
}, [groupId, onChange, persist]);
useLayoutEffect(() => {
const hash = window.location.hash.slice(1);
if (!hash) return;
for (const [value, id] of valueToIdMap.entries()) {
if (id === hash) {
onChange(value);
tabsRef.current?.scrollIntoView();
break;
}
}
}, [onChange, valueToIdMap]);
return (
<Primitive.Tabs
ref={mergeRefs(ref, tabsRef)}
value={value}
onValueChange={(v: string) => {
if (updateAnchor) {
const id = valueToIdMap.get(v);
if (id) {
window.history.replaceState(null, '', `#${id}`);
}
}
if (groupId) {
listeners.get(groupId)?.forEach((item) => {
item(v);
});
if (persist) localStorage.setItem(groupId, v);
else sessionStorage.setItem(groupId, v);
} else {
setValue(v);
}
}}
{...props}
>
<TabsContext.Provider
value={useMemo(() => ({ valueToIdMap }), [valueToIdMap])}
>
{props.children}
</TabsContext.Provider>
</Primitive.Tabs>
);
}
export function TabsContent({
value,
...props
}: ComponentProps<typeof Primitive.TabsContent>) {
const { valueToIdMap } = useTabContext();
if (props.id) {
valueToIdMap.set(value, props.id);
}
return (
<Primitive.TabsContent value={value} {...props}>
{props.children}
</Primitive.TabsContent>
);
}
-69
View File
@@ -1,69 +0,0 @@
'use client';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import * as React from 'react';
import { cn } from '@/utils/cn';
const Tabs = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root>
>((props, ref) => {
return (
<TabsPrimitive.Root
ref={ref}
{...props}
className={cn(
'flex flex-col overflow-hidden rounded-xl border bg-fd-secondary',
props.className,
)}
/>
);
});
Tabs.displayName = 'Tabs';
const TabsList = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>((props, ref) => (
<TabsPrimitive.List
ref={ref}
{...props}
className={cn(
'flex gap-3.5 text-fd-secondary-foreground overflow-x-auto px-4 not-prose',
props.className,
)}
/>
));
TabsList.displayName = 'TabsList';
const TabsTrigger = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>((props, ref) => (
<TabsPrimitive.Trigger
ref={ref}
{...props}
className={cn(
'inline-flex items-center gap-2 whitespace-nowrap text-fd-muted-foreground border-b border-transparent py-2 text-sm font-medium transition-colors [&_svg]:size-4 hover:text-fd-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=active]:border-fd-primary data-[state=active]:text-fd-primary',
props.className,
)}
/>
));
TabsTrigger.displayName = 'TabsTrigger';
const TabsContent = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>((props, ref) => (
<TabsPrimitive.Content
ref={ref}
{...props}
className={cn(
'p-4 text-[15px] bg-fd-background rounded-xl outline-none',
props.className,
)}
/>
));
TabsContent.displayName = 'TabsContent';
export { Tabs, TabsList, TabsTrigger, TabsContent };
+12 -1
View File
@@ -11,7 +11,14 @@ import { Card, Cards } from '@/components/card';
import { Callout } from '@/components/callout';
import { Heading } from '@/components/heading';
import { cn } from '@/utils/cn';
import { CodeBlock, Pre } from '@/components/codeblock';
import {
CodeBlock,
CodeBlockTab,
CodeBlockTabs,
CodeBlockTabsList,
CodeBlockTabsTrigger,
Pre,
} from '@/components/codeblock';
function Image(
props: ImgHTMLAttributes<HTMLImageElement> & {
@@ -37,6 +44,10 @@ function Table(props: TableHTMLAttributes<HTMLTableElement>) {
}
const defaultMdxComponents = {
CodeBlockTab,
CodeBlockTabs,
CodeBlockTabsList,
CodeBlockTabsTrigger,
pre: (props: HTMLAttributes<HTMLPreElement>) => (
<CodeBlock {...props}>
<Pre>{props.children}</Pre>