Files
better-auth/docs/components/ui/code-block.tsx
2025-09-05 09:11:17 -07:00

356 lines
8.2 KiB
TypeScript

"use client";
import { Check, Copy } from "lucide-react";
import {
ButtonHTMLAttributes,
type ComponentProps,
createContext,
forwardRef,
type HTMLAttributes,
ReactElement,
type ReactNode,
type RefObject,
useCallback,
useContext,
useMemo,
useRef,
} from "react";
import { cn } from "@/lib/utils";
import { useCopyButton } from "./use-copy-button";
import { buttonVariants } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { mergeRefs } from "@/lib/utils";
import { ScrollArea, ScrollBar, ScrollViewport } from "./scroll-area";
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<HTMLElement>;
/**
* 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<HTMLDivElement | null>;
nested: boolean;
} | null>(null);
export function Pre(props: ComponentProps<"pre">) {
return (
<pre
{...props}
className={cn("min-w-full w-max *:flex *:flex-col", props.className)}
>
{props.children}
</pre>
);
}
export function CodeBlock({
ref,
title,
allowCopy,
keepBackground = false,
icon,
viewportProps = {},
children,
Actions = (props) => (
<div {...props} className={cn("empty:hidden", props.className)} />
),
...props
}: CodeBlockProps) {
const isTab = useContext(TabsContext) !== null;
const areaRef = useRef<HTMLDivElement>(null);
allowCopy ??= !isTab;
const bg = cn(
"bg-fd-secondary",
keepBackground && "bg-(--shiki-light-bg) dark:bg-(--shiki-dark-bg)",
);
const onCopy = useCallback(() => {
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.remove();
});
void navigator.clipboard.writeText(clone.textContent ?? "");
}, []);
return (
<figure
ref={ref}
dir="ltr"
{...props}
className={cn(
isTab ? [bg, "rounded-lg"] : "my-4 rounded-lg bg-fd-card",
"group shiki relative border shadow-sm outline-none not-prose overflow-hidden text-sm",
props.className,
)}
>
{title ? (
<div
className={cn(
"group flex text-fd-muted-foreground items-center gap-2 ps-3 h-9.5 pr-1 bg-fd-muted",
isTab && "border-b",
)}
>
{typeof icon === "string" ? (
<div
className="[&_svg]:size-3.5"
dangerouslySetInnerHTML={{
__html: icon,
}}
/>
) : (
icon
)}
<figcaption className="flex-1 truncate">{title}</figcaption>
{Actions({
children: allowCopy && <CopyButton onCopy={onCopy} />,
})}
</div>
) : (
Actions({
className: "absolute top-1 right-1 z-2 text-fd-muted-foreground",
children: allowCopy && <CopyButton onCopy={onCopy} />,
})
)}
<div
ref={areaRef}
{...viewportProps}
className={cn(
!isTab && [bg, "rounded-none border border-x-0 border-b-0"],
"text-[13px] overflow-auto max-h-[600px] bg-fd-muted/50 fd-scroll-container",
viewportProps.className,
!title && "border-t-0",
)}
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}`
: undefined,
...viewportProps.style,
} as object
}
>
{children}
</div>
</figure>
);
}
function CopyButton({
className,
onCopy,
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & {
onCopy: () => void;
}): ReactElement {
const [checked, onClick] = useCopyButton(onCopy);
return (
<button
type="button"
className={cn(
buttonVariants({
variant: "ghost",
size: "icon",
}),
"transition-opacity size-7 border-none group-hover:opacity-100",
"opacity-0 group-hover:opacity-100",
"group-hover:opacity-100",
className,
)}
aria-label="Copy Text"
onClick={onClick}
{...props}
>
<Check
className={cn("size-3.5 transition-transform", !checked && "scale-0")}
/>
<Copy
className={cn(
"absolute size-3.5 transition-transform",
checked && "scale-0",
)}
/>
</button>
);
}
export function CodeBlockTabs({ ref, ...props }: ComponentProps<typeof Tabs>) {
const containerRef = useRef<HTMLDivElement>(null);
const nested = useContext(TabsContext) !== null;
return (
<Tabs
ref={mergeRefs(containerRef, ref)}
{...props}
className={cn(
"bg-fd-card p-1 rounded-xl border overflow-hidden",
!nested && "my-4",
props.className,
)}
>
<TabsContext.Provider
value={useMemo(
() => ({
containerRef,
nested,
}),
[nested],
)}
>
{props.children}
</TabsContext.Provider>
</Tabs>
);
}
export function CodeBlockTabsList(props: ComponentProps<typeof TabsList>) {
const { containerRef, nested } = useContext(TabsContext)!;
return (
<TabsList
{...props}
className={cn(
"flex flex-row overflow-x-auto px-1 -mx-1 text-fd-muted-foreground",
props.className,
)}
>
{props.children}
</TabsList>
);
}
export function CodeBlockTabsTrigger({
children,
...props
}: ComponentProps<typeof TabsTrigger>) {
return (
<TabsTrigger
{...props}
className={cn(
"relative group inline-flex text-sm font-medium text-nowrap items-center transition-colors gap-2 px-2 first:ms-1 py-1.5 hover:text-fd-accent-foreground data-[state=active]:text-fd-primary [&_svg]:size-3.5",
props.className,
)}
>
<div className="absolute inset-x-2 bottom-0 h-px group-data-[state=active]:bg-fd-primary" />
{children}
</TabsTrigger>
);
}
// TODO: currently Vite RSC plugin has problem with adding `asChild` here, maybe revisit this in future
export const CodeBlockTab = TabsContent;
export const CodeBlockOld = forwardRef<HTMLElement, CodeBlockProps>(
(
{
title,
allowCopy = true,
keepBackground = false,
icon,
viewportProps,
...props
},
ref,
) => {
const areaRef = useRef<HTMLDivElement>(null);
const onCopy = useCallback(() => {
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.remove();
});
void navigator.clipboard.writeText(clone.textContent ?? "");
}, []);
return (
<figure
ref={ref}
{...props}
className={cn(
"not-prose group fd-codeblock relative my-6 overflow-hidden rounded-lg border bg-fd-secondary/50 text-sm",
keepBackground &&
"bg-[var(--shiki-light-bg)] dark:bg-[var(--shiki-dark-bg)]",
props.className,
)}
>
{title ? (
<div className="flex flex-row items-center gap-2 border-b bg-fd-muted px-4 py-1.5">
{icon ? (
<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} />
) : null}
</div>
) : (
allowCopy && (
<CopyButton
className="absolute right-2 top-2 z-[2] backdrop-blur-md"
onCopy={onCopy}
/>
)
)}
<ScrollArea ref={areaRef} dir="ltr">
<ScrollViewport
{...viewportProps}
className={cn("max-h-[600px]", viewportProps?.className)}
>
{props.children}
</ScrollViewport>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</figure>
);
},
);
CodeBlockOld.displayName = "CodeBlockOld";