Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,12 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import BreadcrumbSimple from "~/components/ui/breadcrumbs/BreadcrumbSimple";
|
||||
|
||||
interface Props {
|
||||
items: { title: string; routePath: string }[];
|
||||
}
|
||||
|
||||
export default function DocBreadcrumb({ items }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return <BreadcrumbSimple menu={[{ title: t("docs.title"), routePath: "/docs" }, ...items]} />;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import CustomCarousel from "../images/CustomCarousel";
|
||||
|
||||
interface Props {
|
||||
items: {
|
||||
type: string;
|
||||
title: string;
|
||||
src: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export default function DocCarousel({ items }: Props) {
|
||||
return <CustomCarousel items={items} />;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { t as i18nT } from "i18next";
|
||||
import CheckIcon from "../icons/CheckIcon";
|
||||
import XIcon from "../icons/XIcon";
|
||||
|
||||
interface Props {
|
||||
items: { route: string; c: boolean; r: boolean; u: boolean; d: boolean }[];
|
||||
}
|
||||
|
||||
export default function DocLifeCycle({ items }: Props) {
|
||||
return (
|
||||
<div className="not-prose flex flex-col">
|
||||
<div className="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div className="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
|
||||
<div className="overflow-hidden shadow-xs ring-1 ring-black ring-opacity-5 md:rounded-lg">
|
||||
<table className="min-w-full divide-y divide-gray-300">
|
||||
<thead className="bg-surface-neutral-secondary">
|
||||
<tr>
|
||||
<th scope="col" className="text-text-neutral-primary w-full px-2 py-1.5 text-left text-sm font-semibold sm:pl-6">
|
||||
{i18nT("components.docs.lifecycle.route")}
|
||||
</th>
|
||||
<th scope="col" className="text-text-neutral-primary px-2 py-1.5 text-center text-sm font-semibold">
|
||||
{i18nT("components.docs.lifecycle.create")}
|
||||
</th>
|
||||
<th scope="col" className="text-text-neutral-primary px-2 py-1.5 text-center text-sm font-semibold">
|
||||
{i18nT("components.docs.lifecycle.read")}
|
||||
</th>
|
||||
<th scope="col" className="text-text-neutral-primary px-2 py-1.5 text-center text-sm font-semibold">
|
||||
{i18nT("components.docs.lifecycle.update")}
|
||||
</th>
|
||||
<th scope="col" className="text-text-neutral-primary px-2 py-1.5 text-center text-sm font-semibold">
|
||||
{i18nT("components.docs.lifecycle.delete")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border bg-surface-neutral-primary divide-y">
|
||||
{items.map((item) => (
|
||||
<tr key={item.route}>
|
||||
<td className="text-text-neutral-primary whitespace-nowrap px-2 py-1.5 text-sm font-medium sm:pl-6">{item.route}</td>
|
||||
<td className="text-text-neutral-tertiary whitespace-nowrap px-2 py-1.5 text-center text-sm">
|
||||
{item.c ? <CheckIcon className="text-theme-500 mx-auto h-4 w-4" /> : <XIcon className="mx-auto h-4 w-4 text-gray-300" />}
|
||||
</td>
|
||||
<td className="text-text-neutral-tertiary whitespace-nowrap px-2 py-1.5 text-center text-sm">
|
||||
{item.r ? <CheckIcon className="text-theme-500 mx-auto h-4 w-4" /> : <XIcon className="mx-auto h-4 w-4 text-gray-300" />}
|
||||
</td>
|
||||
<td className="text-text-neutral-tertiary whitespace-nowrap px-2 py-1.5 text-center text-sm">
|
||||
{item.u ? <CheckIcon className="text-theme-500 mx-auto h-4 w-4" /> : <XIcon className="mx-auto h-4 w-4 text-gray-300" />}
|
||||
</td>
|
||||
<td className="text-text-neutral-tertiary whitespace-nowrap px-2 py-1.5 text-center text-sm">
|
||||
{item.d ? <CheckIcon className="text-theme-500 mx-auto h-4 w-4" /> : <XIcon className="mx-auto h-4 w-4 text-gray-300" />}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { t as i18nT } from "i18next";
|
||||
import { Link } from "react-router";
|
||||
|
||||
interface Props {
|
||||
items: { name: string; title: string; type?: ModelPropertyType; customType?: { name: string; route?: string }; description?: string; required: boolean }[];
|
||||
}
|
||||
export enum ModelPropertyType {
|
||||
String,
|
||||
DateTime,
|
||||
Int,
|
||||
Decimal,
|
||||
Boolean,
|
||||
UUID,
|
||||
Cuid,
|
||||
}
|
||||
|
||||
export default function DocPropertiesTable({ items }: Props) {
|
||||
return (
|
||||
<div className="not-prose flex flex-col">
|
||||
<div className="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div className="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
|
||||
<div className="overflow-hidden shadow-xs ring-1 ring-black ring-opacity-5 md:rounded-lg">
|
||||
<table className="min-w-full divide-y divide-gray-300">
|
||||
<thead className="bg-surface-neutral-secondary">
|
||||
<tr>
|
||||
<th scope="col" className="text-text-neutral-primary px-2 py-1.5 text-left text-sm font-semibold sm:pl-6">
|
||||
{i18nT("components.docs.properties.name")}
|
||||
</th>
|
||||
<th scope="col" className="text-text-neutral-primary px-2 py-1.5 text-left text-sm font-semibold">
|
||||
{i18nT("components.docs.properties.title")}
|
||||
</th>
|
||||
<th scope="col" className="text-text-neutral-primary px-2 py-1.5 text-left text-sm font-semibold">
|
||||
{i18nT("components.docs.properties.type")}
|
||||
</th>
|
||||
<th scope="col" className="text-text-neutral-primary w-full px-2 py-1.5 text-left text-sm font-semibold">
|
||||
{i18nT("components.docs.properties.description")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border bg-surface-neutral-primary divide-y">
|
||||
{items.map((item) => (
|
||||
<tr key={item.name}>
|
||||
<td className="text-text-neutral-primary whitespace-nowrap px-2 py-1.5 text-sm font-medium sm:pl-6">
|
||||
{item.name} {item.required && <span className="ml-1 text-red-500">*</span>}
|
||||
</td>
|
||||
<td className="text-text-neutral-tertiary whitespace-nowrap px-2 py-1.5 text-sm">{item.title}</td>
|
||||
<td className="text-text-neutral-tertiary whitespace-nowrap px-2 py-1.5 text-sm">
|
||||
{item.customType && (
|
||||
<span className="font-bold">
|
||||
{item.customType.route ? (
|
||||
<Link className="text-theme-600 underline" to={item.customType.route}>
|
||||
{item.customType.name}
|
||||
</Link>
|
||||
) : (
|
||||
item.customType.name
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{item.type !== undefined && ModelPropertyType[item.type]}
|
||||
</td>
|
||||
<td className="text-text-neutral-tertiary whitespace-nowrap px-2 py-1.5 text-sm">{item.description}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
interface Props {
|
||||
alt: string;
|
||||
src: string;
|
||||
}
|
||||
|
||||
export default function DocTutorialImage({ alt, src }: Props) {
|
||||
return (
|
||||
<div className="border-border-default border-2 border-dashed">
|
||||
<img className="bg-surface-neutral-secondary/90 mx-auto rounded-lg object-cover shadow-2xl" alt={alt} src={src} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
interface Props {
|
||||
title: string;
|
||||
src: string;
|
||||
}
|
||||
|
||||
export default function DocVideo({ title, src }: Props) {
|
||||
return (
|
||||
<div className="border-border-default h-full border-2 border-dashed">
|
||||
<iframe
|
||||
src={src}
|
||||
title={title ?? ""}
|
||||
frameBorder="0"
|
||||
loading="lazy"
|
||||
className="min-h-full w-full object-cover"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
></iframe>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Fragment, useState } from "react";
|
||||
import { t as i18nT } from "i18next";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "../sheet";
|
||||
import { Button } from "../button";
|
||||
import Logo from "~/components/brand/Logo";
|
||||
import { ScrollArea } from "../scroll-area";
|
||||
import { Link } from "react-router";
|
||||
import clsx from "clsx";
|
||||
import { SideBarItem } from "~/application/sidebar/SidebarItem";
|
||||
import { getUiIdAttributes, UI_ACTION_IDS } from "~/application/ui/uiRegistry";
|
||||
import { MenuIcon } from "~/components/ui/icons/vendor";
|
||||
|
||||
export function MobileNav({ items }: { items: SideBarItem[] }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
data-ui-id={`${UI_ACTION_IDS.sidebarTrigger}.docsMobile`}
|
||||
className="px-0 text-base hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 md:hidden"
|
||||
>
|
||||
<MenuIcon className="text-text-neutral-tertiary h-6 w-6" />
|
||||
<span className="sr-only">{i18nT("components.docsMobileNav.toggleMenu")}</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="pr-0">
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>{i18nT("docs.mobileNav.title")}</SheetTitle>
|
||||
<SheetDescription>{i18nT("docs.mobileNav.description")}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<Logo className="mr-2" />
|
||||
<ScrollArea className="my-4 h-[calc(100vh-100px)] pb-10 pl-3">
|
||||
<div className="flex flex-col space-y-2">
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className="flex flex-col space-y-3 pt-4" {...(!item.path ? getUiIdAttributes(item) : {})}>
|
||||
{item.items && item.items.length > 0 ? (
|
||||
<button type="button" aria-expanded="true" className="text-left font-medium" {...getUiIdAttributes(item)}>
|
||||
{item.title}
|
||||
</button>
|
||||
) : item.path ? (
|
||||
<Link to={item.path} {...getUiIdAttributes(item)}>
|
||||
<h4 className="font-medium">{item.title}</h4>
|
||||
</Link>
|
||||
) : (
|
||||
<h4 className="font-medium">{item.title}</h4>
|
||||
)}
|
||||
{item.items?.map((item) => (
|
||||
<Fragment key={item.path}>
|
||||
{!item.disabled &&
|
||||
(item.path ? (
|
||||
<MobileLink href={item.path} onOpenChange={setOpen} className="text-text-neutral-tertiary" uiItem={item}>
|
||||
{item.title}
|
||||
{item.side && (
|
||||
<span className="ml-2 rounded-md bg-[#adfa1d] px-1.5 py-0.5 text-xs leading-none text-[#000000] no-underline group-hover:no-underline">
|
||||
{item.side}
|
||||
</span>
|
||||
)}
|
||||
</MobileLink>
|
||||
) : (
|
||||
item.title
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileLink({
|
||||
href,
|
||||
onOpenChange,
|
||||
className,
|
||||
children,
|
||||
uiItem,
|
||||
...props
|
||||
}: {
|
||||
href: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
className: string;
|
||||
children: React.ReactNode;
|
||||
uiItem?: SideBarItem;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
onClick={() => {
|
||||
onOpenChange?.(false);
|
||||
}}
|
||||
className={clsx(className)}
|
||||
{...(uiItem ? getUiIdAttributes(uiItem) : {})}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveRepoPath } from "~/utils/testing/repoPaths";
|
||||
import { MobileNav } from "./DocsMobileNav";
|
||||
import { DocsSidebarNav } from "./DocsSidebarNav";
|
||||
|
||||
vi.mock("~/utils/data/useRootData", () => ({
|
||||
useRootData: () => ({
|
||||
appConfiguration: {
|
||||
branding: {},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("docs navigation", () => {
|
||||
it("renders parents with children as expand-only controls", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DocsSidebarNav
|
||||
items={[
|
||||
{
|
||||
title: "Settings",
|
||||
path: "/settings",
|
||||
items: [{ title: "Overview", path: "/settings" }],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Settings" })).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getByRole("link", { name: "Overview" })).toHaveAttribute("href", "/settings");
|
||||
expect(screen.queryByRole("link", { name: "Settings" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render disabled docs items as links", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DocsSidebarNav
|
||||
items={[
|
||||
{
|
||||
title: "Settings",
|
||||
items: [{ title: "Disabled Plugin", path: "/settings/plugin", disabled: true }],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("link", { name: "Disabled Plugin" })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Disabled Plugin")).toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
it("renders an accessible mobile docs sheet title and description", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MobileNav items={[]} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Toggle menu" }));
|
||||
|
||||
const dialog = screen.getByRole("dialog", { name: "Docs navigation" });
|
||||
const heading = screen.getByRole("heading", { name: "Docs navigation" });
|
||||
const description = screen.getByText("Displays the mobile docs navigation.");
|
||||
|
||||
expect(dialog).toHaveAccessibleDescription("Displays the mobile docs navigation.");
|
||||
expect(heading.closest('[data-slot="sheet-header"]')).toHaveClass("sr-only");
|
||||
expect(description.closest('[data-slot="sheet-header"]')).toHaveClass("sr-only");
|
||||
});
|
||||
|
||||
it("keeps mobile docs parents as non-link controls", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MobileNav
|
||||
items={[
|
||||
{
|
||||
title: "Settings",
|
||||
path: "/settings",
|
||||
items: [{ title: "Overview", path: "/settings" }],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Toggle menu" }));
|
||||
|
||||
expect(screen.getByRole("button", { name: "Settings" })).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getByRole("link", { name: "Overview" })).toHaveAttribute("href", "/settings");
|
||||
expect(screen.queryByRole("link", { name: "Settings" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds an explicit overview child for knowledge-base category paths", () => {
|
||||
const source = fs.readFileSync(resolveRepoPath(path.join("apps/web/app/modules/knowledgeBase/components/layouts/KbDocsLayout.tsx")), "utf8");
|
||||
|
||||
expect(source).toContain('items: [{ title: t("shared.overview"), path: category.href }]');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Link, useLocation } from "react-router";
|
||||
import clsx from "clsx";
|
||||
import { SideBarItem } from "~/application/sidebar/SidebarItem";
|
||||
import { getUiIdAttributes } from "~/application/ui/uiRegistry";
|
||||
|
||||
export function DocsSidebarNav({ items }: { items: SideBarItem[] }) {
|
||||
return (
|
||||
<div>
|
||||
{items.length > 0 ? (
|
||||
<div className="w-full">
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className={clsx(item.items && item.items.length > 0 && "pb-4")} {...(!item.path ? getUiIdAttributes(item) : {})}>
|
||||
{item.items && item.items.length > 0 ? (
|
||||
<button type="button" aria-expanded="true" className="mb-1 rounded-md px-2 py-1 text-left font-semibold" {...getUiIdAttributes(item)}>
|
||||
{item.title}
|
||||
</button>
|
||||
) : item.path ? (
|
||||
<Link to={item.path} className="hover:underline" {...getUiIdAttributes(item)}>
|
||||
<h4 className="mb-1 rounded-md px-2 py-1 font-semibold">{item.title}</h4>
|
||||
</Link>
|
||||
) : (
|
||||
<h4 className="mb-1 rounded-md px-2 py-1 font-semibold">{item.title}</h4>
|
||||
)}
|
||||
{item.items && item.items.length > 0 && <DocsSidebarNavItems items={item.items} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DocsSidebarNavItems({ items }: { items: SideBarItem[] }) {
|
||||
const location = useLocation();
|
||||
const pathname = location.pathname;
|
||||
return items?.length ? (
|
||||
<div className="grid grid-flow-row auto-rows-max">
|
||||
{items.map((item, index) =>
|
||||
item.path && !item.disabled ? (
|
||||
<Link
|
||||
key={index}
|
||||
to={item.path}
|
||||
className={clsx(
|
||||
"group flex w-full items-center rounded-md border border-transparent px-2 py-1 text-sm hover:underline",
|
||||
item.disabled && "cursor-not-allowed opacity-60",
|
||||
pathname === item.path ? "text-text-neutral-primary font-medium" : "text-text-neutral-tertiary"
|
||||
)}
|
||||
target={item.target}
|
||||
rel={item.target ? "noreferrer" : ""}
|
||||
{...getUiIdAttributes(item)}
|
||||
// preventScrollReset
|
||||
>
|
||||
{item.title}
|
||||
{item.side && (
|
||||
<span className="ml-2 rounded-md bg-[#adfa1d] px-1.5 py-0.5 text-xs leading-none text-[#000000] no-underline group-hover:no-underline">
|
||||
{item.side}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
) : (
|
||||
<span
|
||||
key={index}
|
||||
aria-disabled={item.disabled ? "true" : undefined}
|
||||
className={clsx(
|
||||
"text-text-neutral-tertiary flex w-full items-center rounded-md p-2",
|
||||
!item.disabled && "hover:underline",
|
||||
item.disabled && "cursor-not-allowed opacity-60"
|
||||
)}
|
||||
{...getUiIdAttributes(item)}
|
||||
>
|
||||
{item.title}
|
||||
{item.side && (
|
||||
<span className="bg-surface-neutral-secondary text-text-neutral-tertiary ml-2 rounded-md px-1.5 py-0.5 text-xs leading-none no-underline group-hover:no-underline">
|
||||
{item.side}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { DashboardTableOfContents } from "./toc";
|
||||
import clsx from "clsx";
|
||||
|
||||
export type PreviewComponentProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
variants:
|
||||
| {
|
||||
id: string;
|
||||
title: string;
|
||||
component: ReactNode;
|
||||
code?: string;
|
||||
description?: string;
|
||||
}[]
|
||||
| {
|
||||
id: string;
|
||||
title: string;
|
||||
iframe: string;
|
||||
code?: string;
|
||||
description?: string;
|
||||
}[];
|
||||
className?: string;
|
||||
withToc?: boolean;
|
||||
overflow?: "hidden" | "visible";
|
||||
};
|
||||
export default function PreviewComponent({ title, description, variants, className, withToc = true, overflow = "hidden" }: PreviewComponentProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<main className={clsx("relative pb-6 lg:gap-10 lg:pb-8", withToc && "xl:grid xl:grid-cols-[1fr_150px]")}>
|
||||
<div className="space-y-8 overflow-hidden">
|
||||
<div>
|
||||
<h1 className="scroll-m-20 text-4xl font-bold tracking-tight">{title}</h1>
|
||||
<p className="text-muted-foreground mt-1 text-lg">{description}</p>
|
||||
</div>
|
||||
{variants.map((variant, index) => (
|
||||
<div key={index} className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<h3 id={variant.id} className="text-xl font-bold tracking-tight">
|
||||
{variant.title}
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">{variant.description}</p>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
"border-muted-foreground flex justify-center rounded-lg border-2 border-dashed",
|
||||
className,
|
||||
overflow === "hidden" ? "overflow-hidden" : "overflow-visible",
|
||||
"component" in variant && "p-6"
|
||||
)}
|
||||
>
|
||||
{"iframe" in variant ? <iframe src={variant.iframe} title={title} className="h-96 w-full border-0" /> : variant.component}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{withToc && (
|
||||
<div className="hidden text-sm xl:block">
|
||||
<div className="sticky top-16 pt-4">
|
||||
<ScrollArea className="pb-10">
|
||||
<div className="sticky top-16 -mt-10 h-[calc(100vh-3.5rem)] py-12">
|
||||
<DashboardTableOfContents
|
||||
// title={`${title} - Variants`}
|
||||
title={t("docs.onThisPage")}
|
||||
toc={{
|
||||
items: variants
|
||||
.map((f) => f)
|
||||
.map((item) => ({
|
||||
title: item.title,
|
||||
url: `#${item.id}`,
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import clsx from "clsx";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import ButtonEvent from "../buttons/ButtonEvent";
|
||||
|
||||
interface Item {
|
||||
title: string;
|
||||
url: string;
|
||||
items?: Item[];
|
||||
}
|
||||
|
||||
interface Items {
|
||||
items?: Item[];
|
||||
}
|
||||
|
||||
interface TocProps {
|
||||
title: string;
|
||||
toc: Items;
|
||||
}
|
||||
|
||||
export function DashboardTableOfContents({ title, toc }: TocProps) {
|
||||
const itemIds = useMemo(
|
||||
() =>
|
||||
toc.items
|
||||
? toc.items
|
||||
.flatMap((item) => [item.url, item?.items?.map((item) => item.url)])
|
||||
.flat()
|
||||
.filter(Boolean)
|
||||
.map((id) => id?.split("#")[1])
|
||||
: [],
|
||||
[toc]
|
||||
);
|
||||
const activeHeading = useActiveItem(itemIds.filter(Boolean) as string[]);
|
||||
const mounted = useMounted();
|
||||
|
||||
if (!toc?.items || !mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">{title}</p>
|
||||
<Tree tree={toc} activeItem={activeHeading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useActiveItem(itemIds: string[]) {
|
||||
const [activeId, setActiveId] = useState<undefined | string>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setActiveId(entry.target.id ?? undefined);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ rootMargin: `0% 0% -80% 0%` }
|
||||
);
|
||||
|
||||
itemIds?.forEach((id) => {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
observer.observe(element);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
itemIds?.forEach((id) => {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
observer.unobserve(element);
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [itemIds]);
|
||||
|
||||
return activeId;
|
||||
}
|
||||
|
||||
interface TreeProps {
|
||||
tree: Items;
|
||||
level?: number;
|
||||
activeItem?: string;
|
||||
}
|
||||
|
||||
function Tree({ tree, level = 1, activeItem }: TreeProps) {
|
||||
return tree?.items?.length && level < 3 ? (
|
||||
<ul className={clsx("m-0 list-none", { "pl-4": level !== 1 })}>
|
||||
{tree.items.map((item, index) => {
|
||||
return (
|
||||
<li key={index} className={clsx("mt-0 pt-2")}>
|
||||
<ButtonEvent
|
||||
event={{ action: "click", category: "toc", label: item.title, value: item.url }}
|
||||
to={item.url}
|
||||
className={clsx(
|
||||
"hover:text-text-neutral-primary inline-block no-underline transition-colors",
|
||||
item.url === `#${activeItem}` ? "text-text-neutral-primary font-medium" : "text-text-neutral-tertiary"
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</ButtonEvent>
|
||||
{item.items?.length ? <Tree tree={item} level={level + 1} activeItem={activeItem} /> : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : null;
|
||||
}
|
||||
|
||||
function useMounted() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
return mounted;
|
||||
}
|
||||
Reference in new issue
Block a user