mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
Core & UI: Support separate component for Codeblock Tabs
This commit is contained in:
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
@@ -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]"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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%);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user