Skip to content

Commit

Permalink
components: Add Tabs
Browse files Browse the repository at this point in the history
  • Loading branch information
tbantle22 committed Mar 20, 2024
1 parent 7b6a8f0 commit ff6e416
Show file tree
Hide file tree
Showing 12 changed files with 378 additions and 1 deletion.
1 change: 1 addition & 0 deletions packages/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"react-dom": "^18"
},
"dependencies": {
"@dolthub/react-contexts": "^0.1.0",
"@dolthub/react-hooks": "^0.1.7",
"@dolthub/web-utils": "^0.1.3",
"@react-icons/all-files": "^4.1.0",
Expand Down
41 changes: 41 additions & 0 deletions packages/components/src/Tabs/Tab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import cx from "classnames";
import React, { ReactNode } from "react";
import Btn from "../Btn";
import { useTabsContext } from "./context";
import css from "./index.module.css";

type Props = {
children: ReactNode;
className?: string;
index: number;
name?: string;
renderOnlyChild?: boolean;
hide?: boolean;
};

export default function Tab(props: Props) {
const { activeTabIndex, setActiveTabIndex } = useTabsContext();
const isActive = props.index === activeTabIndex;
const tabLabel = `tab${props.name ? `-${props.name}` : ""}`;
const label = `${isActive ? "active-" : ""}${tabLabel}`;

if (props.hide) return null;

return (
<li
data-cy={label}
aria-label={label}
className={cx(css.tab, props.className, {
[css.activeTab]: isActive,
})}
>
{props.renderOnlyChild ? (
props.children
) : (
<Btn onClick={() => setActiveTabIndex(props.index)}>
{props.children}
</Btn>
)}
</li>
);
}
12 changes: 12 additions & 0 deletions packages/components/src/Tabs/TabList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import cx from "classnames";
import React, { ReactNode } from "react";
import css from "./index.module.css";

type Props = {
children: ReactNode[];
className?: string;
};

export default function TabList(props: Props) {
return <ul className={cx(css.tabList, props.className)}>{props.children}</ul>;
}
14 changes: 14 additions & 0 deletions packages/components/src/Tabs/TabPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import React, { ReactNode } from "react";
import { useTabsContext } from "./context";

type Props = {
children: ReactNode;
className?: string;
index: number;
};

export default function TabPanel(props: Props) {
const { activeTabIndex } = useTabsContext();
if (props.index !== activeTabIndex) return null;
return <div className={props.className}>{props.children}</div>;
}
36 changes: 36 additions & 0 deletions packages/components/src/Tabs/context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { createCustomContext } from "@dolthub/react-contexts";
import { useContextWithError } from "@dolthub/react-hooks";
import React, { ReactNode, useMemo, useState } from "react";

type TabsContextType = {
activeTabIndex: number;
setActiveTabIndex: React.Dispatch<React.SetStateAction<number>>;
};

export const TabsContext = createCustomContext<TabsContextType>("TabsContext");

type Props = {
children: ReactNode;
initialActiveIndex?: number;
};

export function TabsProvider(props: Props) {
const [activeTabIndex, setActiveTabIndex] = useState(
props.initialActiveIndex ?? 0,
);

const value = useMemo(() => {
return {
activeTabIndex,
setActiveTabIndex,
};
}, [activeTabIndex, setActiveTabIndex]);

return (
<TabsContext.Provider value={value}>{props.children}</TabsContext.Provider>
);
}

export function useTabsContext(): TabsContextType {
return useContextWithError(TabsContext);
}
39 changes: 39 additions & 0 deletions packages/components/src/Tabs/index.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
.tabList {
@apply w-full overflow-x-auto flex border-b border-ld-lightgrey;

@media (max-width: 756px) {
/* Hide scrollbar for IE, Edge, and Firefox */
-ms-overflow-style: none;
scrollbar-width: none;
/* Hide scrollbar for Chrome, Safari and Opera */
&::-webkit-scrollbar {
@apply hidden;
}
}
}

.tab {
@apply px-4 mx-3;

button,
a {
@apply flex justify-center items-center mt-1 min-w-full px-0 font-semibold pb-2;
}
}

.tab:hover,
.activeTab {
@apply text-acc-1 border-b-3 border-acc-1;

a {
@apply text-acc-1;
}
}

.tabs {
@apply w-full;

@screen md {
@apply w-auto;
}
}
23 changes: 23 additions & 0 deletions packages/components/src/Tabs/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import cx from "classnames";
import React, { ReactNode } from "react";
import Tab from "./Tab";
import TabList from "./TabList";
import TabPanel from "./TabPanel";
import { TabsProvider } from "./context";
import css from "./index.module.css";

type Props = {
children: ReactNode[];
initialActiveIndex?: number;
className?: string;
};

function Tabs({ children, ...props }: Props) {
return (
<TabsProvider {...props}>
<div className={cx(css.tabs, props.className)}>{children}</div>
</TabsProvider>
);
}

export { Tab, TabList, TabPanel, Tabs };
93 changes: 93 additions & 0 deletions packages/components/src/__stories__/Tabs.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import type { Meta, StoryObj } from "@storybook/react";
import React from "react";
import { Tab, TabList, TabPanel, Tabs } from "../Tabs";

const meta: Meta<typeof Tabs> = {
title: "Tabs",
component: Tabs,
tags: ["autodocs"],
};

export default meta;

type Story = StoryObj<typeof Tabs>;

function Panel(props: { text: string; idx: number }) {
return (
<TabPanel index={props.idx}>
<div className="my-6 mx-3">{props.text}</div>
</TabPanel>
);
}

const tabs = ["Tab 1", "Tab 2"];
const panels = [
<Panel key="1" text="Content 1 for Tab 1" idx={0} />,
<Panel key="2" text="Content 2 for Tab 2" idx={1} />,
];

export const Basic: Story = {
args: {
initialActiveIndex: 0,
children: [
<TabList key="tabList">
{tabs.map((tab, index) => (
<Tab key={tab} index={index}>
{tab}
</Tab>
))}
</TabList>,
...panels,
],
},
};

export const SecondActive: Story = {
args: {
initialActiveIndex: 1,
children: [
<TabList key="tabList">
{tabs.map((tab, index) => (
<Tab key={tab} index={index}>
{tab}
</Tab>
))}
</TabList>,
...panels,
],
},
};

export const HideLastTab: Story = {
args: {
initialActiveIndex: 0,
children: [
<TabList key="tabList">
{tabs.map((tab, index) => (
<Tab key={tab} index={index}>
{tab}
</Tab>
))}
<Tab index={2} hide>
Tab 3
</Tab>
</TabList>,
...panels,
],
},
};

export const TabWithLink: Story = {
args: {
children: [
<TabList key="tabList">
{tabs.map((tab, index) => (
<Tab key={tab} index={index} renderOnlyChild>
<a href="#">{tab}</a>
</Tab>
))}
</TabList>,
...panels,
],
},
};
114 changes: 114 additions & 0 deletions packages/components/src/__tests__/Tabs.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { fireEvent, render, screen } from "@testing-library/react";
import React from "react";
import { Tab, TabList, TabPanel, Tabs } from "../Tabs";

const mocks = [
{ tabWord: "zero", panelWord: "Cero" },
{ tabWord: "one", panelWord: "Uno" },
{ tabWord: "two", panelWord: "Dos" },
{ tabWord: "three", panelWord: "Tres", hide: true },
];

const testingActiveTab = (
elementCollection: HTMLElement[],
activeIndex: number,
) => {
elementCollection.forEach(currentTab => {
if (currentTab === elementCollection[activeIndex]) {
expect(currentTab).toHaveAttribute("aria-label", "active-tab");
expect(currentTab).not.toHaveAttribute("aria-label", "tab");
} else {
expect(currentTab).not.toHaveAttribute("aria-label", "active-tab");
expect(currentTab).toHaveAttribute("aria-label", "tab");
}
});
};

describe("test Tabs", () => {
it(`renders active tabs and panels correctly`, () => {
render(
<Tabs>
<TabList>
{mocks.map((mock, i) => (
<Tab
index={i}
data-cy={mock.tabWord}
key={`tab-${mock.tabWord}`}
aria-label="tab"
hide={mock.hide}
>
{mock.tabWord}
</Tab>
))}
</TabList>
{mocks.map((mock, i) => (
<TabPanel index={i} key={`tabPanel-${mock.panelWord}`}>
{mock.panelWord}
</TabPanel>
))}
</Tabs>,
);
const listItems = screen.getAllByRole("listitem");
const buttons = screen.getAllByRole("button");

for (let i = 0; i < mocks.length; i++) {
if (mocks[i].hide) {
expect(screen.queryByText(mocks[i].panelWord)).not.toBeInTheDocument();
expect(screen.queryByText(mocks[i].tabWord)).not.toBeInTheDocument();
} else {
if (i !== 0) {
fireEvent.click(buttons[i]);
}

testingActiveTab(listItems, i);
const firstPage = screen.queryByText("Cero");
const secondPage = screen.queryByText("Uno");
const thirdPage = screen.queryByText("Dos");
const pages = [firstPage, secondPage, thirdPage];

pages.forEach((page, idx) => {
if (i === idx) {
expect(page).toBeInTheDocument();
} else {
expect(page).not.toBeInTheDocument();
}
});
}
}
});
it(`renders initialActiveIndex correctly`, () => {
render(
<Tabs initialActiveIndex={2}>
<TabList aria-label="tabList">
{mocks.map((mock, i) => (
<Tab
index={i}
data-cy={mock.tabWord}
key={`tab-${mock.tabWord}`}
aria-label="tab"
>
{mock.tabWord}
</Tab>
))}
</TabList>
{mocks.map((mock, i) => (
<TabPanel index={i} key={`tabPanel-${mock.panelWord}`}>
{mock.panelWord}
</TabPanel>
))}
</Tabs>,
);

const listItems = screen.getAllByRole("listitem");

testingActiveTab(listItems, 2);

const firstPage = screen.queryByText("Cero");
const secondPage = screen.queryByText("Uno");
const thirdPage = screen.queryByText("Dos");

expect(thirdPage).toBeInTheDocument();
expect(firstPage).not.toBeInTheDocument();
expect(secondPage).not.toBeInTheDocument();
});
});
2 changes: 2 additions & 0 deletions packages/components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export { default as Popup, PopupProps } from "./Popup";
export { default as Radio } from "./Radio";
export { default as SmallLoader } from "./SmallLoader";
export { default as SuccessMsg } from "./SuccessMsg";
export * from "./Tabs";
export { useTabsContext } from "./Tabs/context";
export { default as Textarea } from "./Textarea";
export { default as ThemeProvider, useThemeContext } from "./tailwind/context";
export { mergeConfig } from "./tailwind/mergeConfig";
Expand Down
Loading

0 comments on commit ff6e416

Please sign in to comment.