Skip to content

Commit

Permalink
feat: Add sandbox component and improve tabs component
Browse files Browse the repository at this point in the history
Implemented a new Sandbox component that allows the embedding of dynamic code editors like CodeSandbox and Stackblitz inside documentation. Moreover, enhanced the existing Tabs component by adding more flexible control of tab states. They were added secondary index settings and implemented an option to preserve the tab state in local storage, both changes to increase usability and user experience.

Signed-off-by: prisis <d.bannert@anolilab.de>
  • Loading branch information
prisis committed Aug 2, 2023
1 parent 6c5d803 commit aeb6378
Show file tree
Hide file tree
Showing 11 changed files with 267 additions and 38 deletions.
4 changes: 4 additions & 0 deletions docs/theme.config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import cn from "clsx";
import { useRouter } from "next/router";
import { DiscordIcon } from "nextra/icons";
import {
Box,
ChevronDownSquare,
Club,
Edit,
Expand Down Expand Up @@ -241,6 +242,9 @@ const config: DocumentationThemeConfig = {
case "/docs/nextra-theme-docs/writing-content/components/live-editor": {
return <Edit className={className} />;
}
case "/docs/nextra-theme-docs/writing-content/components/sandbox": {
return <Box className={className} />;
}
default: {
return null;
}
Expand Down
3 changes: 3 additions & 0 deletions examples/nextra/pages/docs/advanced/sandbox.en-US.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { Sandbox } from "@visulima/nextra-theme-docs";

<Sandbox repo="vercel/next.js" branch="canary" dir="examples/active-class-name" />
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
Footprints,
ListTree,
Edit,
Box,
} from "lucide-react";

`Anolilab Nextra Theme Docs` provides a library of build-in-components to make your docs beautiful without formatting things yourself.
Expand Down Expand Up @@ -42,4 +43,5 @@ They can be added directly within your files. Some components are rendered from

<CardGroup>
<Card title="React Live Editor" href="/docs/nextra-theme-docs/writing-content/components/live-editor" icon={<Edit size={20} className="mt-0.5" />} />
<Card title="Sandbox" href="/docs/nextra-theme-docs/writing-content/components/sandbox" icon={<Box size={20} className="mt-0.5" />} />
</CardGroup>
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Sandbox } from "@visulima/nextra-theme-docs";

<Sandbox repo="vercel/next.js" branch="canary" dir="examples/active-class-name" />

## API

The `Sandbox` component takes the following props:

### `repo`

- Type: `string`
- Default: ``

The GitHub repository to use. This should be in the format `owner/repo`.

### `branch`

- Type: `string`
- Default: `main`

The branch to use. Defaults to `main`.

### `dir`

- Type: `string`
- Default: ``

The directory to use. Defaults to the root of the repository.

### `file`

- Type: `string`
- Default: ``

The file to use.

### `minHeight`

- Type: `string`
- Default: `700px`

The minimum height of the iframe. Defaults to `700px`.
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,16 @@ The `Tabs` component takes the following props:

The index of the tab that should be selected by default.

- Type: `string | number`
- Type: `number`
- Default: `0`

### `selectedIndex`

The index of the tab that should be selected.

- Type: `number`
- Default: `undefined`

### `children`

The content of the tabs.
Expand Down Expand Up @@ -94,3 +101,26 @@ Whether the tab is disabled.

- Type: `boolean`
- Default: `false`

### `storageKey`

The key used to store the tab state in local storage.

- Type: `string`
- Default: `undefined`

### `disableScrollBar`

Whether the tab should disable the scroll bar.

- Type: `boolean`
- Default: `false`

### `classes`

The classes used to style the tab and tabs (root).

- Type: `object`
- tab: `string`
- tabs: `string`
- Default: `undefined`
66 changes: 66 additions & 0 deletions packages/nextra-theme-docs/src/components/sandbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { FC } from "react";
import { useTheme } from "next-themes";
import { useConfig } from "../contexts";
import { Tab, Tabs } from "./tabs";

const defaultProviders: Record<string, string> = {
CodeSandbox: `https://codesandbox.io/embed/github/{repo}/tree/{branch}/{dir}?hidenavigation=1&theme={colorMode}`,
StackBlitz: `https://stackblitz.com/github/{repo}/tree/{branch}/{dir}?embed=1&file={file}&theme={colorMode}`,
};
const Sandbox: FC<{
branch?: string;
dir?: string;
file: string;
minHeight?: string;
repo?: string;
src?: string;
// eslint-disable-next-line unicorn/prevent-abbreviations
}> = ({ branch = "main", dir = "", file, minHeight = "700px", repo: repository = "", src: source = undefined }) => {
const { sandbox } = useConfig();
const { resolvedTheme } = useTheme();

const providers: Record<string, string> = { ...defaultProviders, ...sandbox?.providers };

Object.keys(providers).forEach((key: string) => {
if (source) {
// eslint-disable-next-line security/detect-object-injection
providers[key] = source;
}

// eslint-disable-next-line security/detect-object-injection
providers[key] = (providers[key] as string)
.replace("{repo}", repository)
.replace("{branch}", branch)
.replace("{dir}", dir)
.replace("{file}", file)
.replace("{colorMode}", resolvedTheme ?? "light");
});

return (
<Tabs
/* eslint-disable-next-line @arthurgeron/react-usememo/require-usememo */
classes={{
tabs: "relative text-white bg-gray-200 rounded-t-lg dark:bg-gray-700",
}}
disableScrollBar
prefix="sandbox"
storageKey="sandbox"
>
{Object.keys(providers).map((key: string) => (
<Tab className="not-prose relative" key={key} style={{ minHeight }} title={key}>
<span className="absolute inset-x-0 top-20 m-auto block w-40 text-center">Loading Sandbox...</span>
<iframe
className="not-prose absolute left-0 top-0 z-10 h-full w-full overflow-hidden"
sandbox="allow-modals allow-forms allow-popups allow-scripts allow-same-origin"
/* eslint-disable-next-line security/detect-object-injection */
src={providers[key]}
style={{ minHeight, width: "100%" }}
title="Sandbox editor"
/>
</Tab>
))}
</Tabs>
);
};

export default Sandbox;
125 changes: 94 additions & 31 deletions packages/nextra-theme-docs/src/components/tabs.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Tab as HeadlessTab } from "@headlessui/react";
import cn from "clsx";
import type { ComponentProps, FC, ReactElement, ReactNode } from "react";
import { Children, isValidElement, useId } from "react";
import { Children, isValidElement, useCallback, useEffect, useId, useState } from "react";
import cn from "../utils/cn";

interface TabItem {
disabled?: boolean;
Expand All @@ -10,30 +10,70 @@ interface TabItem {

type TabProperties = ComponentProps<"div"> & { disabled?: boolean; title: ReactElement | string };

export const Tab: FC<TabProperties> = ({ children = undefined, title, ...properties }) => (
export const Tab: FC<TabProperties> = ({ children = undefined, className = "pt-3", title, ...properties }) => (
// @ts-expect-error TS2322: Type 'string' is not assignable to type 'Ref<HTMLElement> | undefined'
// eslint-disable-next-line react/jsx-props-no-spreading
<HeadlessTab.Panel {...properties} className="pt-3">
<HeadlessTab.Panel {...properties} className={className}>
{children}
</HeadlessTab.Panel>
);

export const Tabs = ({
children,
defaultIndex = undefined,
classes = undefined,
defaultIndex = 0,
disableScrollBar = false,
onChange = undefined,
prefix = "tabs",
selectedIndex = undefined,
selectedIndex: _selectedIndex = undefined,
storageKey = undefined,
}: {
children: ReactNode | ReactNode[];
classes?: {
tab?: string;
tabs?: string;
};
defaultIndex?: number;
disableScrollBar?: boolean;
onChange?: (index: number) => void;
prefix?: string;
selectedIndex?: number;
storageKey?: string;
}): ReactElement => {
const id = useId();
const tabs: TabItem[] = [];

const [selectedIndex, setSelectedIndex] = useState(defaultIndex);

useEffect(() => {
if (_selectedIndex !== undefined) {
setSelectedIndex(_selectedIndex);
}
}, [_selectedIndex]);

useEffect(() => {
if (!storageKey) {
// Do not listen storage events if there is no storage key
return;
}

const handleEvent = (event: StorageEvent) => {
if (event.key === storageKey) {
setSelectedIndex(Number(event.newValue));
}
};

const index = Number(localStorage.getItem(storageKey));
setSelectedIndex(Number.isNaN(index) ? 0 : index);

window.addEventListener("storage", handleEvent);

// eslint-disable-next-line consistent-return
return () => {
window.removeEventListener("storage", handleEvent);
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps -- only on mount

Children.forEach(Children.toArray(children), (child) => {
if (isValidElement(child)) {
if ((child as ReactElement<TabProperties>).type === Tab) {
Expand All @@ -48,32 +88,55 @@ export const Tabs = ({
}
});

const handleChange = useCallback((index: number) => {
if (storageKey) {
const newValue = String(index);

localStorage.setItem(storageKey, newValue);

// the storage event only get picked up (by the listener) if the localStorage was changed in
// another browser's tab/window (of the same app), but not within the context of the current tab.
window.dispatchEvent(new StorageEvent("storage", { key: storageKey, newValue }));
return;
}

setSelectedIndex(index);
onChange?.(index);
}, []); // eslint-disable-line react-hooks/exhaustive-deps -- only on mount

let content = (
<HeadlessTab.List className={cn("mt-4 flex w-max min-w-full border-b border-gray-400 pb-px dark:border-neutral-800", classes?.tabs)}>
{tabs.map((item, index) => (
<HeadlessTab
/* eslint-disable-next-line @arthurgeron/react-usememo/require-usememo */
className={({ selected }) =>
cn(
"py-3 px-4 font-medium leading-5 transition-colors text-sm",
"-mb-0.5 select-none border-b-2",
selected
? "border-primary-500 text-primary-600"
: "border-transparent text-gray-600 hover:border-gray-400 hover:text-black dark:text-gray-200 dark:hover:border-neutral-800 dark:hover:text-white",
{ "pointer-events-none text-gray-400 dark:text-neutral-400": item.disabled },
classes?.tab,
)
}
disabled={item.disabled}
// eslint-disable-next-line react/no-array-index-key
key={`${prefix}-${id}-tab-${index}`}
>
{item.label}
</HeadlessTab>
))}
</HeadlessTab.List>
);

if (!disableScrollBar) {
content = <div className="nextra-scrollbar not-prose overflow-x-auto overflow-y-hidden overscroll-x-contain p-0">{content}</div>;
}

return (
<HeadlessTab.Group defaultIndex={defaultIndex} onChange={onChange} selectedIndex={selectedIndex}>
<div className="nextra-scrollbar not-prose overflow-x-auto overflow-y-hidden overscroll-x-contain">
<HeadlessTab.List className="mt-4 flex w-max min-w-full border-b border-gray-400 pb-px pl-1 dark:border-neutral-800">
{tabs.map((item, index) => (
<HeadlessTab
/* eslint-disable-next-line @arthurgeron/react-usememo/require-usememo */
className={({ selected }) =>
cn(
"mr-2 rounded-t p-2 font-medium leading-5 transition-colors",
"-mb-0.5 select-none border-b-2",
selected
? "border-primary-500 text-primary-600"
: "border-transparent text-gray-600 hover:border-gray-400 hover:text-black dark:text-gray-200 dark:hover:border-neutral-800 dark:hover:text-white",
{ "pointer-events-none text-gray-400 dark:text-neutral-600": item.disabled },
)
}
disabled={item.disabled}
// eslint-disable-next-line react/no-array-index-key
key={`${prefix}-${id}-tab-${index}`}
>
{item.label}
</HeadlessTab>
))}
</HeadlessTab.List>
</div>
<HeadlessTab.Group defaultIndex={defaultIndex} onChange={handleChange} selectedIndex={selectedIndex}>
{content}
<HeadlessTab.Panels>{children}</HeadlessTab.Panels>
</HeadlessTab.Group>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ export const DEFAULT_THEME: DocumentationThemeConfig = {
dismissible: true,
key: "nextra-banner",
},
content: {
showDescription: true,
showTitle: true,
},
darkMode: true,
direction: "ltr",
docsRepositoryBase: "https://github.com/shuding/nextra",
Expand Down
5 changes: 3 additions & 2 deletions packages/nextra-theme-docs/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ export { default as Anchor } from "./components/anchor";
export { default as Bleed } from "./components/bleed";
export { default as Callout } from "./components/callout";
export { default as Card } from "./components/card";
export { default as CardGroup } from "./components/card-group";
// @deprecated Use CardGroup instead
export { default as Cards } from "./components/card-group";
export { default as CardGroup } from "./components/card-group";
export { default as Code } from "./components/code";
export { default as Collapse } from "./components/collapse";
export { default as FileTree } from "./components/file-tree";
Expand All @@ -17,12 +17,13 @@ export { default as Navbar } from "./components/navbar";
export { default as NotFoundPage } from "./components/not-found";
export { default as Pre } from "./components/pre";
export { default as Prose } from "./components/prose";
export { default as Sandbox } from "./components/sandbox";
export { default as Search } from "./components/search";
export { default as ServerSideErrorPage } from "./components/server-side-error";
export { SkipNavLink } from "./components/skip-nav";
export { default as Steps } from "./components/step-container";
// @deprecated Use Steps instead
export { default as StepContainer } from "./components/step-container";
export { default as Steps } from "./components/step-container";
export { default as Table } from "./components/table";
export { Tab, Tabs } from "./components/tabs";
export { default as Td } from "./components/td";
Expand Down

1 comment on commit aeb6378

@vercel
Copy link

@vercel vercel bot commented on aeb6378 Aug 2, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.