Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions components/docs/code-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { highlight } from "fumadocs-core/highlight";
import type { ReactNode } from "react";
import { codeTheme } from "@/lib/docs/code-theme";
import { cn } from "@/lib/utils";
import { CodeFrame, CodeTab } from "./code-frame";
import { CodeFrame, CodeTitle } from "./code-frame";

type CodeBlockProps = {
code: string;
Expand Down Expand Up @@ -34,7 +34,7 @@ export const highlightCode = (code: string, lang = "tsx") =>

export const CodeBlock = async ({ code, lang = "tsx", title, className }: CodeBlockProps) => (
<CodeFrame
tabs={<CodeTab active={Boolean(title)}>{title ?? lang}</CodeTab>}
tabs={<CodeTitle active={Boolean(title)}>{title ?? lang}</CodeTitle>}
code={code}
className={className}
>
Expand Down
35 changes: 23 additions & 12 deletions components/docs/code-files.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"use client";

import { Tabs } from "@base-ui/react/tabs";
import type { ReactNode } from "react";
import { useState } from "react";
import { CodeFrame, CodeTab } from "./code-frame";
import { CodeFrame, CodePanel, CodeTab, CodeTabList } from "./code-frame";

export type CodeFileEntry = {
name: string;
Expand All @@ -18,16 +19,26 @@ export const CodeFiles = ({ files, className }: { files: CodeFileEntry[]; classN
const current = files.find((file) => file.name === name) ?? files[0];

return (
<CodeFrame
className={className}
code={current.source}
tabs={files.map((file) => (
<CodeTab key={file.name} active={file.name === name} onClick={() => setName(file.name)}>
{file.name}
</CodeTab>
))}
>
{current.code}
</CodeFrame>
<Tabs.Root value={name} onValueChange={setName}>
<CodeFrame
className={className}
code={current.source}
tabs={
<CodeTabList>
{files.map((file) => (
<CodeTab key={file.name} value={file.name}>
{file.name}
</CodeTab>
))}
</CodeTabList>
}
>
{files.map((file) => (
<CodePanel key={file.name} value={file.name}>
{file.code}
</CodePanel>
))}
</CodeFrame>
</Tabs.Root>
);
};
93 changes: 65 additions & 28 deletions components/docs/code-frame.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { Tabs } from "@base-ui/react/tabs";
import { Check, Copy } from "@keyline-icons/react";
import type { ReactNode } from "react";
import { useCopy } from "@/hooks/use-copy";
Expand All @@ -9,6 +10,8 @@ import { cn } from "@/lib/utils";
* The dark code chrome shared by every block in the docs: a header of file (or
* package-manager) tabs with a copy button, over an inset code panel. Dark in
* both themes, tinted by the theme's seeds (see --code-bg in globals.css).
* A tabbed frame sits inside a `Tabs.Root`, with a `CodeTabList` as its
* `tabs` and a `CodePanel` per tab as its children.
*/
export const CodeFrame = ({
tabs,
Expand All @@ -25,12 +28,12 @@ export const CodeFrame = ({
<div
data-code-block=""
className={cn(
"not-prose flex flex-col rounded-xl bg-code-bg px-[5px] pb-[5px] shadow-code",
"not-prose flex flex-col rounded-xl bg-code-bg px-1.5 pb-1.5 shadow-code",
className,
)}
>
<div className="flex h-10 shrink-0 items-center justify-between gap-2 pr-0.5 pl-[11px]">
<div className="flex h-full min-w-0 items-center gap-4 overflow-x-auto">{tabs}</div>
<div className="flex h-10 shrink-0 items-center justify-between gap-2">
<div className="flex h-full min-w-0 items-center overflow-x-auto">{tabs}</div>
<CodeCopyButton code={code} />
</div>
<div className="overflow-hidden rounded-md bg-code-panel shadow-[inset_0_0_0_1px_rgb(255_255_255/0.07)]">
Expand All @@ -39,36 +42,70 @@ export const CodeFrame = ({
</div>
);

/** A header tab. Without `onClick` it is a plain label (a single file's name). */
export const CodeTab = ({
/** The header's tab strip. The indicator slides to the active tab's label. */
export const CodeTabList = ({ children }: { children: ReactNode }) => (
<Tabs.List className="relative flex h-full items-center">
{children}
<Tabs.Indicator
renderBeforeHydration
className={cn(
"absolute bottom-0 left-0 h-0.5 rounded-t-full bg-white/92",
// Inset 8px into the label on each side.
"w-[calc(var(--active-tab-width)-32px)] translate-x-[calc(var(--active-tab-left)+16px)]",
"transition-[translate,width] duration-200 ease-out motion-reduce:transition-none",
)}
/>
</Tabs.List>
);

/** A header tab, inside a `CodeTabList`. */
export const CodeTab = ({ value, children }: { value: string; children: ReactNode }) => (
<Tabs.Tab
value={value}
className={cn(
tabLabel,
"isolate cursor-pointer text-white/50 outline-none hover:text-white/80 data-active:text-white/92",
// A centred rounded square behind the label on hover or keyboard focus.
"before:absolute before:inset-x-0 before:inset-y-1.5 before:-z-10 before:rounded-md before:transition-colors",
"not-data-active:hover:before:bg-white/8 focus-visible:before:bg-white/8",
)}
>
{children}
</Tabs.Tab>
);

/** One tab's code, as a child of a tabbed `CodeFrame`. */
export const CodePanel = ({ value, children }: { value: string; children: ReactNode }) => (
<Tabs.Panel
value={value}
className="rounded-md focus-visible:outline-2 focus-visible:outline-white/60 focus-visible:-outline-offset-2"
>
{children}
</Tabs.Panel>
);

/** A plain header label in place of tabs: a single file's name, or dimmed, its language. */
export const CodeTitle = ({
active = true,
onClick,
children,
}: {
active?: boolean;
onClick?: () => void;
children: ReactNode;
}) => {
const className = cn(
"flex h-full shrink-0 items-center font-medium font-mono text-xs transition-colors",
active
? "text-white/92 shadow-[inset_0_-1px_0_rgb(255_255_255/0.92)]"
: "text-white/50 hover:text-white/80",
);
}) => (
<span
className={cn(
tabLabel,
active
? "text-white/92 after:absolute after:inset-x-4 after:bottom-0 after:h-0.5 after:rounded-t-full after:bg-white/92"
: "text-white/50",
)}
>
{children}
</span>
);

return onClick ? (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={cn(className, "cursor-pointer outline-none focus-visible:text-white")}
>
{children}
</button>
) : (
<span className={className}>{children}</span>
);
};
const tabLabel =
"relative flex h-full shrink-0 items-center px-2 font-medium font-mono text-xs transition-colors";

const CodeCopyButton = ({ code }: { code: string }) => {
const { copy, copied } = useCopy();
Expand All @@ -79,7 +116,7 @@ const CodeCopyButton = ({ code }: { code: string }) => {
type="button"
onClick={() => copy(code)}
aria-label={isCopied ? "Copied" : "Copy code"}
className="grid size-7 shrink-0 cursor-pointer place-items-center rounded-full text-white/56 transition-colors hover:bg-white/8 hover:text-white focus-visible:outline-2 focus-visible:outline-white/60"
className="grid size-7 shrink-0 cursor-pointer place-items-center rounded-md text-white/56 transition-colors hover:bg-white/8 hover:text-white focus-visible:outline-2 focus-visible:outline-white/60"
>
{isCopied ? <Check className="size-[15px]" /> : <Copy className="size-[15px]" />}
</button>
Expand Down
41 changes: 0 additions & 41 deletions components/docs/installation-block-tabs.tsx

This file was deleted.

13 changes: 7 additions & 6 deletions components/docs/installation-block.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
import { PACKAGE_MANAGERS } from "@/lib/docs/package-managers";
import { highlightCode } from "./code-block";
import { InstallationBlockTabs } from "./installation-block-tabs";
import { CodeFiles } from "./code-files";

type InstallationBlockProps = {
packageName: string;
};

// Server component: highlights one install command per package manager and
// hands them all to the client shell, which shows the selected one.
// shows them as tabs, one per manager.
export const InstallationBlock = async ({ packageName }: InstallationBlockProps) => (
<InstallationBlockTabs
entries={
<CodeFiles
className="my-6"
files={
await Promise.all(
PACKAGE_MANAGERS.map(async ({ manager, install }) => {
const command = `${install} ${packageName}`;
return { manager, command, code: await highlightCode(command, "bash") };
const source = `${install} ${packageName}`;
return { name: manager, source, code: await highlightCode(source, "bash") };
}),
)
}
Expand Down
Loading